Compare commits

...

18 Commits

Author SHA1 Message Date
hyung-hwan ac72bd3c7c relocated the go to file to the right panel of the git detail view 2026-06-24 14:51:36 +09:00
hyung-hwan db2e0f6537 added go to file box 2026-06-24 14:39:48 +09:00
hyung-hwan b841cd00d7 adjusted vertical spacing in the left directory list 2026-06-24 13:01:37 +09:00
hyung-hwan a7eb5dd636 implemented a directory view in git repo detail page 2026-06-24 12:28:12 +09:00
hyung-hwan a353bb51ba some touch up on git views 2026-06-24 12:09:29 +09:00
hyung-hwan e56ea9c03e adjusted font size and weight 2026-06-24 10:44:37 +09:00
hyung-hwan 4f390ad1d5 added the left side menu feel lighter by adjusting the font weight 2026-06-24 10:09:16 +09:00
hyung-hwan 98f685e2ed adjusted font weights globally 2026-06-24 10:05:27 +09:00
hyung-hwan eefe3eaf80 added PillNavButton 2026-06-24 09:53:31 +09:00
hyung-hwan 7b8d4832aa fixed diff line background calcuation 2026-06-24 09:45:24 +09:00
hyung-hwan 9a5f12c21c enhanced the repo git commit details page with collapible panel per file 2026-06-24 09:02:30 +09:00
hyung-hwan 809c4ff038 Removed the history tab from the git code view, instaed let the history to open the commit view 2026-06-24 08:11:35 +09:00
hyung-hwan 15b7277204 fixed a bug in showing path breadcrubm 2026-06-24 00:08:12 +09:00
hyung-hwan a43cc34d74 updated to use tables in git branches and commits pages 2026-06-23 23:59:41 +09:00
hyung-hwan 83c5d5a0d8 pagination fix in RepoGitCommitsPage 2026-06-23 21:10:25 +09:00
hyung-hwan 599dbacf48 pagination to the issued certificates 2026-06-23 21:02:13 +09:00
hyung-hwan b5f84ce6f5 search filter to acme profiles and acme orders in admin pki page 2026-06-23 20:56:43 +09:00
hyung-hwan 6f08eed52c some constants defined for pki status 2026-06-23 20:52:20 +09:00
38 changed files with 1776 additions and 354 deletions
+7 -5
View File
@@ -139,13 +139,14 @@ func (s *Store) ListActivePKIClientProfilesForUser(userID string) ([]models.PKIC
LEFT JOIN users gu ON gm.user_id = gu.id LEFT JOIN users gu ON gm.user_id = gu.id
LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id
WHERE p.enabled = 1 WHERE p.enabled = 1
AND ca.status = 'active' AND ca.status = ?
AND ( AND (
(t.target_type = 'user' AND tu.public_id = ?) (t.target_type = 'user' AND tu.public_id = ?)
OR OR
(t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?)) (t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
) )
ORDER BY p.name`, ORDER BY p.name`,
models.PKIStatusActive,
strings.TrimSpace(userID), strings.TrimSpace(userID),
strings.TrimSpace(userID), strings.TrimSpace(userID),
) )
@@ -275,13 +276,14 @@ func (s *Store) GetActivePKIClientProfileForUser(profileID string, userID string
LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id
WHERE p.public_id = ? WHERE p.public_id = ?
AND p.enabled = 1 AND p.enabled = 1
AND ca.status = 'active' AND ca.status = ?
AND ( AND (
(t.target_type = 'user' AND tu.public_id = ?) (t.target_type = 'user' AND tu.public_id = ?)
OR OR
(t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?)) (t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
)`, )`,
strings.TrimSpace(profileID), strings.TrimSpace(profileID),
models.PKIStatusActive,
strings.TrimSpace(userID), strings.TrimSpace(userID),
strings.TrimSpace(userID), strings.TrimSpace(userID),
) )
@@ -521,7 +523,7 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
i.authz_scope, i.authz_scope,
i.not_before, i.not_before,
i.not_after, i.not_after,
COALESCE(c.status, 'deleted'), COALESCE(c.status, ?),
COALESCE(c.revoked_at, 0), COALESCE(c.revoked_at, 0),
COALESCE(c.revocation_reason, ''), COALESCE(c.revocation_reason, ''),
i.created_at i.created_at
@@ -530,7 +532,7 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
LEFT JOIN users u ON u.id = i.user_id LEFT JOIN users u ON u.id = i.user_id
LEFT JOIN pki_client_profiles p ON p.id = i.profile_id LEFT JOIN pki_client_profiles p ON p.id = i.profile_id
WHERE u.public_id = ? WHERE u.public_id = ?
ORDER BY i.created_at DESC`, strings.TrimSpace(userID)) ORDER BY i.created_at DESC`, models.PKIStatusDeleted, strings.TrimSpace(userID))
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -78,7 +78,7 @@ func (s *Store) CreatePKICA(item models.PKICA) (models.PKICA, error) {
item.SerialCounter = 1 item.SerialCounter = 1
} }
if item.Status == "" { if item.Status == "" {
item.Status = "active" item.Status = models.PKIStatusActive
} }
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.CreatedAt = now item.CreatedAt = now
@@ -309,7 +309,7 @@ func (s *Store) CreatePKICert(item models.PKICert) (models.PKICert, error) {
item.ID = id item.ID = id
} }
if item.Status == "" { if item.Status == "" {
item.Status = "active" item.Status = models.PKIStatusActive
} }
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.CreatedAt = now item.CreatedAt = now
@@ -325,7 +325,7 @@ func (s *Store) CreatePKICert(item models.PKICert) (models.PKICert, error) {
func (s *Store) RevokePKICert(id string, reason string) error { func (s *Store) RevokePKICert(id string, reason string) error {
var err error var err error
_, err = s.Exec(`UPDATE pki_certs SET status = 'revoked', revoked_at = ?, revocation_reason = ? WHERE public_id = ?`, time.Now().UTC().Unix(), reason, id) _, err = s.Exec(`UPDATE pki_certs SET status = ?, revoked_at = ?, revocation_reason = ? WHERE public_id = ?`, models.PKIStatusRevoked, time.Now().UTC().Unix(), reason, id)
return err return err
} }
+304
View File
@@ -35,6 +35,17 @@ type TreeEntry struct {
Type string `json:"type"` Type string `json:"type"`
} }
// TreeEntryCommit is a directory entry plus the most recent commit that touched
// it (the path itself for a file, or anything under it for a subdirectory).
type TreeEntryCommit struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
LastHash string `json:"last_hash"`
LastMessage string `json:"last_message"`
LastWhen string `json:"last_when"`
}
var ErrPathNotFound = errors.New("path not found") var ErrPathNotFound = errors.New("path not found")
const timeFormat = "2006-01-02 15:04:05Z" const timeFormat = "2006-01-02 15:04:05Z"
@@ -680,6 +691,299 @@ func ListTree(repoPath, ref, path string) ([]TreeEntry, error) {
return entries, nil return entries, nil
} }
// treeCommitWalkCap bounds how many commits ListTreeWithCommits will walk before
// giving up on any entries still missing last-commit info. Keeps the listing
// responsive on very deep histories at the cost of leaving rare, long-untouched
// entries without commit metadata.
const treeCommitWalkCap = 2000
// ListTreeWithCommits lists the entries of a directory (like ListTree) and, for
// each, the most recent commit that changed it on the given ref. It uses a
// single first-parent revwalk that stops as soon as every entry is resolved (or
// the walk cap is hit), attributing each changed path to its top-level entry
// under the directory.
func ListTreeWithCommits(repoPath, ref, path string) ([]TreeEntryCommit, error) {
var repo *git2go.Repository
var err error
var commit *git2go.Commit
var tree *git2go.Tree
var subEntry *git2go.TreeEntry
var entries []TreeEntryCommit
var index map[string]int
var unresolved map[string]bool
var prefix string
var walk *git2go.RevWalk
var oid *git2go.Oid
var diffOpts git2go.DiffOptions
var walked int
var i uint64
var count uint64
repo, err = git2go.OpenRepository(repoPath)
if err != nil {
return nil, err
}
defer repo.Free()
commit, err = resolveCommit(repo, ref)
if err != nil {
if isReferenceNotFound(err) {
return []TreeEntryCommit{}, nil
}
return nil, err
}
defer commit.Free()
tree, err = commit.Tree()
if err != nil {
return nil, err
}
defer tree.Free()
if path != "" {
subEntry, err = tree.EntryByPath(path)
if err != nil {
if git2go.IsErrorCode(err, git2go.ErrorCodeNotFound) {
return nil, ErrPathNotFound
}
return nil, err
}
if subEntry.Type != git2go.ObjectTree {
return nil, ErrPathNotFound
}
tree.Free()
tree, err = repo.LookupTree(subEntry.Id)
if err != nil {
return nil, err
}
defer tree.Free()
prefix = path + "/"
}
count = tree.EntryCount()
entries = make([]TreeEntryCommit, 0, count)
index = make(map[string]int, count)
unresolved = make(map[string]bool, count)
for i = 0; i < count; i++ {
var entry *git2go.TreeEntry
var typeName string
var entryPath string
entry = tree.EntryByIndex(i)
typeName = "file"
if entry.Type == git2go.ObjectTree {
typeName = "dir"
}
entryPath = prefix + entry.Name
index[entryPath] = len(entries)
unresolved[entryPath] = true
entries = append(entries, TreeEntryCommit{Name: entry.Name, Path: entryPath, Type: typeName})
}
if len(entries) == 0 {
return entries, nil
}
diffOpts, err = git2go.DefaultDiffOptions()
if err != nil {
return nil, err
}
if path != "" {
diffOpts.Pathspec = []string{path}
}
walk, err = repo.Walk()
if err != nil {
return nil, err
}
defer walk.Free()
walk.Sorting(git2go.SortTime | git2go.SortTopological)
err = walk.Push(commit.Id())
if err != nil {
return nil, err
}
oid = new(git2go.Oid)
for len(unresolved) > 0 && walked < treeCommitWalkCap {
var c *git2go.Commit
err = walk.Next(oid)
if git2go.IsErrorCode(err, git2go.ErrorCodeIterOver) {
break
}
if err != nil {
return nil, err
}
walked++
c, err = repo.LookupCommit(oid)
if err != nil {
return nil, err
}
err = assignTreeCommit(repo, c, prefix, &diffOpts, index, unresolved, entries)
c.Free()
if err != nil {
return nil, err
}
}
return entries, nil
}
// assignTreeCommit attributes commit c to any still-unresolved directory entry
// it changed relative to its first parent (or, for a root commit, to whatever
// remains). It mutates entries/unresolved in place.
func assignTreeCommit(repo *git2go.Repository, c *git2go.Commit, prefix string, diffOpts *git2go.DiffOptions, index map[string]int, unresolved map[string]bool, entries []TreeEntryCommit) error {
var cTree *git2go.Tree
var parent *git2go.Commit
var parentTree *git2go.Tree
var diff *git2go.Diff
var err error
var numDeltas int
var i int
cTree, err = c.Tree()
if err != nil {
return err
}
defer cTree.Free()
if c.ParentCount() == 0 {
// Root commit: attribute every remaining entry that exists here.
var key string
for key = range unresolved {
markTreeCommit(c, key, index, unresolved, entries)
}
return nil
}
parent = c.Parent(0)
defer parent.Free()
parentTree, err = parent.Tree()
if err != nil {
return err
}
defer parentTree.Free()
diff, err = repo.DiffTreeToTree(parentTree, cTree, diffOpts)
if err != nil {
return err
}
defer diff.Free()
numDeltas, err = diff.NumDeltas()
if err != nil {
return err
}
for i = 0; i < numDeltas; i++ {
var delta git2go.DiffDelta
var changed string
var child string
delta, err = diff.Delta(i)
if err != nil {
return err
}
changed = delta.NewFile.Path
if changed == "" {
changed = delta.OldFile.Path
}
child = topChildUnder(prefix, changed)
if child != "" {
markTreeCommit(c, child, index, unresolved, entries)
}
}
return nil
}
// topChildUnder maps a changed file path to the directory entry it belongs to:
// the first path component beneath prefix (so a deeply-nested change is
// attributed to the subdirectory entry). Returns "" if changed is not under
// prefix.
func topChildUnder(prefix string, changed string) string {
if prefix != "" {
if !strings.HasPrefix(changed, prefix) {
return ""
}
changed = changed[len(prefix):]
}
var slash int
slash = strings.IndexByte(changed, '/')
if slash >= 0 {
changed = changed[:slash]
}
if changed == "" {
return ""
}
return prefix + changed
}
func markTreeCommit(c *git2go.Commit, entryPath string, index map[string]int, unresolved map[string]bool, entries []TreeEntryCommit) {
var idx int
var ok bool
if !unresolved[entryPath] {
return
}
idx, ok = index[entryPath]
if !ok {
return
}
entries[idx].LastHash = c.Id().String()
entries[idx].LastMessage = strings.TrimSpace(c.Message())
entries[idx].LastWhen = c.Author().When.UTC().Format(timeFormat)
delete(unresolved, entryPath)
}
// listAllFilesCap bounds how many file paths ListAllFiles returns, keeping the
// payload reasonable for pathological monorepos. Callers should treat a result
// of exactly this length as possibly truncated.
const listAllFilesCap = 50000
// errWalkStop aborts a tree walk early once the file cap is reached.
var errWalkStop = errors.New("tree walk stopped")
// ListAllFiles returns every file (blob) path in the repository at ref, as a
// flat slice (like `git ls-tree -r --name-only <ref>`). It is a single
// recursive tree walk of one commit — cheap and independent of history.
func ListAllFiles(repoPath, ref string) ([]string, error) {
var repo *git2go.Repository
var commit *git2go.Commit
var tree *git2go.Tree
var files []string
var err error
repo, err = git2go.OpenRepository(repoPath)
if err != nil {
return nil, err
}
defer repo.Free()
commit, err = resolveCommit(repo, ref)
if err != nil {
if isReferenceNotFound(err) {
return []string{}, nil
}
return nil, err
}
defer commit.Free()
tree, err = commit.Tree()
if err != nil {
return nil, err
}
defer tree.Free()
files = make([]string, 0, 256)
err = tree.Walk(func(root string, entry *git2go.TreeEntry) error {
if entry.Type == git2go.ObjectBlob {
files = append(files, root+entry.Name)
if len(files) >= listAllFilesCap {
return errWalkStop
}
}
return nil
})
if err != nil && err != errWalkStop {
return nil, err
}
return files, nil
}
func ReadBlob(repoPath, ref, path string, maxBytes int64) (string, error) { func ReadBlob(repoPath, ref, path string, maxBytes int64) (string, error) {
var data []byte var data []byte
var err error var err error
+1 -1
View File
@@ -636,7 +636,7 @@ func (api *API) FinalizeACMEOrder(w http.ResponseWriter, r *http.Request, params
KeyPEM: order.KeyPEM, KeyPEM: order.KeyPEM,
NotBefore: leaf.NotBefore.Unix(), NotBefore: leaf.NotBefore.Unix(),
NotAfter: leaf.NotAfter.Unix(), NotAfter: leaf.NotAfter.Unix(),
Status: "active", Status: models.PKIStatusActive,
RevokedAt: 0, RevokedAt: 0,
RevocationReason: "", RevocationReason: "",
} }
+48 -2
View File
@@ -3739,6 +3739,52 @@ func (api *API) RepoTree(w http.ResponseWriter, r *http.Request, params map[stri
WriteJSON(w, http.StatusOK, entries) WriteJSON(w, http.StatusOK, entries)
} }
func (api *API) RepoFiles(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var ref string
var files []string
var unlock func()
var ok bool
repo, unlock, ok = api.lockResolvedRepoForRead(w, r, params["id"], models.RoleViewer)
if !ok { return }
defer unlock()
ref = r.URL.Query().Get("ref")
files, err = git.ListAllFiles(repo.Path, ref)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, files)
}
func (api *API) RepoTreeCommits(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var ref string
var path string
var entries []git.TreeEntryCommit
var unlock func()
var ok bool
repo, unlock, ok = api.lockResolvedRepoForRead(w, r, params["id"], models.RoleViewer)
if !ok { return }
defer unlock()
ref = r.URL.Query().Get("ref")
path = r.URL.Query().Get("path")
entries, err = git.ListTreeWithCommits(repo.Path, ref, path)
if err != nil {
if errors.Is(err, git.ErrPathNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "path not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, entries)
}
func (api *API) RepoBlob(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) RepoBlob(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo var repo models.Repo
var err error var err error
@@ -6388,7 +6434,7 @@ func validateTLSPKIServerCert(store *db.Store, certID string, required bool) err
if cert.IsCA { if cert.IsCA {
return errors.New("selected TLS PKI server certificate is a CA") return errors.New("selected TLS PKI server certificate is a CA")
} }
if cert.Status != "active" { if cert.Status != models.PKIStatusActive {
return errors.New("selected TLS PKI server certificate is not active") return errors.New("selected TLS PKI server certificate is not active")
} }
parsed, err = parseCertificateFromPEM(cert.CertPEM) parsed, err = parseCertificateFromPEM(cert.CertPEM)
@@ -6417,7 +6463,7 @@ func validateTLSPKIClientCA(store *db.Store, caID string, required bool) error {
if err != nil { if err != nil {
return errors.New("selected TLS PKI client CA not found") return errors.New("selected TLS PKI client CA not found")
} }
if ca.Status != "active" { if ca.Status != models.PKIStatusActive {
return errors.New("selected TLS PKI client CA is not active") return errors.New("selected TLS PKI client CA is not active")
} }
parsed, err = parseCertificateFromPEM(ca.CertPEM) parsed, err = parseCertificateFromPEM(ca.CertPEM)
+9 -12
View File
@@ -343,7 +343,7 @@ func (api *API) CreatePKIRootCA(w http.ResponseWriter, r *http.Request, _ map[st
return return
} }
} }
ca = models.PKICA{Name: strings.TrimSpace(req.Name), ParentCAID: "", IsRoot: true, CertPEM: certPEM, KeyPEM: keyPEM, SerialCounter: 1, Status: "active"} ca = models.PKICA{Name: strings.TrimSpace(req.Name), ParentCAID: "", IsRoot: true, CertPEM: certPEM, KeyPEM: keyPEM, SerialCounter: 1, Status: models.PKIStatusActive}
ca, err = api.store(r).CreatePKICA(ca) ca, err = api.store(r).CreatePKICA(ca)
if err != nil { if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error()) WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
@@ -415,7 +415,7 @@ func (api *API) CreatePKIIntermediateCA(w http.ResponseWriter, r *http.Request,
return return
} }
} }
child = models.PKICA{Name: strings.TrimSpace(req.Name), ParentCAID: parent.ID, IsRoot: false, CertPEM: certPEM, KeyPEM: keyPEM, SerialCounter: 1, Status: "active"} child = models.PKICA{Name: strings.TrimSpace(req.Name), ParentCAID: parent.ID, IsRoot: false, CertPEM: certPEM, KeyPEM: keyPEM, SerialCounter: 1, Status: models.PKIStatusActive}
child, err = api.store(r).CreatePKICA(child) child, err = api.store(r).CreatePKICA(child)
if err != nil { if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error()) WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
@@ -543,7 +543,7 @@ func (api *API) IssuePKICert(w http.ResponseWriter, r *http.Request, _ map[strin
return return
} }
createdByKind, createdBySubjectID, createdBySubjectName, issuanceSource = pkiCertProvenanceFromRequest(r, "admin_issue") createdByKind, createdBySubjectID, createdBySubjectName, issuanceSource = pkiCertProvenanceFromRequest(r, "admin_issue")
cert = models.PKICert{CAID: ca.ID, SerialHex: serialHex, CommonName: strings.TrimSpace(req.CommonName), SANDNS: strings.Join(req.SANDNS, ","), SANIPs: strings.Join(req.SANIPs, ","), IsCA: req.IsCA, CertPEM: certPEM, KeyPEM: keyPEM, NotBefore: notBefore, NotAfter: notAfter, Status: "active"} cert = models.PKICert{CAID: ca.ID, SerialHex: serialHex, CommonName: strings.TrimSpace(req.CommonName), SANDNS: strings.Join(req.SANDNS, ","), SANIPs: strings.Join(req.SANIPs, ","), IsCA: req.IsCA, CertPEM: certPEM, KeyPEM: keyPEM, NotBefore: notBefore, NotAfter: notAfter, Status: models.PKIStatusActive}
cert.CreatedByKind = createdByKind cert.CreatedByKind = createdByKind
cert.CreatedBySubjectID = createdBySubjectID cert.CreatedBySubjectID = createdBySubjectID
cert.CreatedBySubjectName = createdBySubjectName cert.CreatedBySubjectName = createdBySubjectName
@@ -661,7 +661,7 @@ func (api *API) ImportPKICert(w http.ResponseWriter, r *http.Request, _ map[stri
KeyPEM: keyPEM, KeyPEM: keyPEM,
NotBefore: importedCert.NotBefore.UTC().Unix(), NotBefore: importedCert.NotBefore.UTC().Unix(),
NotAfter: importedCert.NotAfter.UTC().Unix(), NotAfter: importedCert.NotAfter.UTC().Unix(),
Status: "active", Status: models.PKIStatusActive,
RevokedAt: 0, RevokedAt: 0,
RevocationReason: "", RevocationReason: "",
} }
@@ -780,7 +780,7 @@ func (api *API) RevokePKICert(w http.ResponseWriter, r *http.Request, params map
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found") WriteJSONWithErrorReason(w, r, http.StatusNotFound, "certificate not found")
return return
} }
if cert.Status == "revoked" { if cert.Status == models.PKIStatusRevoked {
WriteJSON(w, http.StatusOK, map[string]string{"status": "already revoked"}) WriteJSON(w, http.StatusOK, map[string]string{"status": "already revoked"})
return return
} }
@@ -793,7 +793,7 @@ func (api *API) RevokePKICert(w http.ResponseWriter, r *http.Request, params map
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return return
} }
WriteJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) WriteJSON(w, http.StatusOK, map[string]string{"status": models.PKIStatusRevoked})
} }
func (api *API) DeletePKICert(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) DeletePKICert(w http.ResponseWriter, r *http.Request, params map[string]string) {
@@ -1360,13 +1360,10 @@ func buildCRLPEM(ca models.PKICA, certs []models.PKICert) (string, error) {
return "", err return "", err
} }
for i = 0; i < len(certs); i++ { for i = 0; i < len(certs); i++ {
if certs[i].Status != "revoked" { if certs[i].Status != models.PKIStatusRevoked { continue }
continue
}
serial, ok = new(big.Int).SetString(strings.TrimSpace(certs[i].SerialHex), 16) serial, ok = new(big.Int).SetString(strings.TrimSpace(certs[i].SerialHex), 16)
if !ok || serial == nil { if !ok || serial == nil { continue }
continue
}
if certs[i].RevokedAt > 0 { if certs[i].RevokedAt > 0 {
when = time.Unix(certs[i].RevokedAt, 0).UTC() when = time.Unix(certs[i].RevokedAt, 0).UTC()
} else { } else {
+4
View File
@@ -21,3 +21,7 @@ const UserGroupScopeAllUsers string = "all_users"
const RPMRepoModeLocal string = "local" const RPMRepoModeLocal string = "local"
const RPMRepoModeMirror string = "mirror" const RPMRepoModeMirror string = "mirror"
const PKIStatusActive string = "active"
const PKIStatusRevoked string = "revoked"
const PKIStatusDeleted string = "deleted"
+2
View File
@@ -1107,6 +1107,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("POST", "/api/repos/:id/branches/create", api.RepoCreateBranch) router.Handle("POST", "/api/repos/:id/branches/create", api.RepoCreateBranch)
router.Handle("GET", "/api/repos/:id/commits", api.RepoCommits) router.Handle("GET", "/api/repos/:id/commits", api.RepoCommits)
router.Handle("GET", "/api/repos/:id/tree", api.RepoTree) router.Handle("GET", "/api/repos/:id/tree", api.RepoTree)
router.Handle("GET", "/api/repos/:id/tree-commits", api.RepoTreeCommits)
router.Handle("GET", "/api/repos/:id/files", api.RepoFiles)
router.Handle("GET", "/api/repos/:id/blob", api.RepoBlob) router.Handle("GET", "/api/repos/:id/blob", api.RepoBlob)
router.Handle("GET", "/api/repos/:id/blob/raw", api.RepoBlobRaw) router.Handle("GET", "/api/repos/:id/blob/raw", api.RepoBlobRaw)
router.Handle("GET", "/api/repos/:id/history", api.RepoFileHistory) router.Handle("GET", "/api/repos/:id/history", api.RepoFileHistory)
+21 -1
View File
@@ -309,6 +309,12 @@ export interface RepoTreeEntry {
type: 'file' | 'dir' type: 'file' | 'dir'
} }
export interface RepoTreeEntryCommit extends RepoTreeEntry {
last_hash: string
last_message: string
last_when: string
}
export interface Issue { export interface Issue {
id: string id: string
project_id: string project_id: string
@@ -2012,16 +2018,30 @@ export const api = {
const qs = params.toString() const qs = params.toString()
return request<RepoTreeEntry[]>(`/api/repos/${repoId}/tree${qs ? `?${qs}` : ''}`) return request<RepoTreeEntry[]>(`/api/repos/${repoId}/tree${qs ? `?${qs}` : ''}`)
}, },
listRepoTreeCommits: (repoId: string, ref?: string, path?: string) => {
const params = new URLSearchParams()
if (ref) params.set('ref', ref)
if (path) params.set('path', path)
const qs = params.toString()
return request<RepoTreeEntryCommit[]>(`/api/repos/${repoId}/tree-commits${qs ? `?${qs}` : ''}`)
},
listRepoFiles: (repoId: string, ref?: string) => {
const params = new URLSearchParams()
if (ref) params.set('ref', ref)
const qs = params.toString()
return request<string[]>(`/api/repos/${repoId}/files${qs ? `?${qs}` : ''}`)
},
getRepoBlob: (repoId: string, ref: string | undefined, path: string) => { getRepoBlob: (repoId: string, ref: string | undefined, path: string) => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (ref) params.set('ref', ref) if (ref) params.set('ref', ref)
params.set('path', path) params.set('path', path)
return request<{ path: string; content: string }>(`/api/repos/${repoId}/blob?${params.toString()}`) return request<{ path: string; content: string }>(`/api/repos/${repoId}/blob?${params.toString()}`)
}, },
listRepoFileHistory: (repoId: string, ref: string | undefined, path: string) => { listRepoFileHistory: (repoId: string, ref: string | undefined, path: string, limit?: number) => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (ref) params.set('ref', ref) if (ref) params.set('ref', ref)
params.set('path', path) params.set('path', path)
if (limit !== undefined) params.set('limit', String(limit))
return request<RepoCommit[]>(`/api/repos/${repoId}/history?${params.toString()}`) return request<RepoCommit[]>(`/api/repos/${repoId}/history?${params.toString()}`)
}, },
getRepoFileDiff: (repoId: string, ref: string | undefined, path: string) => { getRepoFileDiff: (repoId: string, ref: string | undefined, path: string) => {
+2 -2
View File
@@ -306,7 +306,7 @@ export default function Layout() {
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ display: 'block', px: 2, py: 0.75, fontWeight: 600, letterSpacing: 0.2 }} sx={{ display: 'block', px: 2, py: 0.75, fontWeight: 500, letterSpacing: 0.2 }}
> >
{section.label} {section.label}
</Typography> </Typography>
@@ -337,7 +337,7 @@ export default function Layout() {
{item.icon} {item.icon}
<ListItemText <ListItemText
primary={item.label} primary={item.label}
primaryTypographyProps={{ noWrap: true }} primaryTypographyProps={{ noWrap: true, variant: 'body2', sx: { fontWeight: 400 } }}
sx={{ sx={{
minWidth: 0, minWidth: 0,
overflow: 'hidden', overflow: 'hidden',
+21 -1
View File
@@ -177,7 +177,27 @@ export function createAppTheme(mode: PaletteMode, scheme: ThemeScheme) {
} }
}, },
typography: { typography: {
fontFamily fontFamily,
h5: {
fontSize: '1.2rem',
fontWeight: 300,
lineHeight: 1.35,
},
h6: {
fontSize: '1.02rem',
fontWeight: 300,
lineHeight: 1.35,
},
subtitle1: {
fontSize: '0.98rem',
fontWeight: 300,
lineHeight: 1.35,
},
subtitle2: {
fontSize: '0.9rem',
fontWeight: 300,
lineHeight: 1.35,
}
}, },
components: { components: {
MuiDialog: { MuiDialog: {
+1 -1
View File
@@ -71,7 +71,7 @@ export default function BoardCardContent(props: BoardCardContentProps) {
{props.dragHandle} {props.dragHandle}
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500, textDecoration: completed ? 'line-through' : 'none', flex: 1 }}> <Typography variant="body2" sx={{ fontWeight: 400, textDecoration: completed ? 'line-through' : 'none', flex: 1 }}>
{props.card.title} {props.card.title}
</Typography> </Typography>
{completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null} {completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null}
+8 -8
View File
@@ -111,7 +111,7 @@ function ChartTooltip({ tt }: { tt: TooltipState }) {
boxShadow: '0 4px 16px rgba(0,0,0,0.18)', zIndex: 10, whiteSpace: 'nowrap', boxShadow: '0 4px 16px rgba(0,0,0,0.18)', zIndex: 10, whiteSpace: 'nowrap',
color: text, color: text,
}}> }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{tt.data.title}</div> <div style={{ fontWeight: 400, marginBottom: 6 }}>{tt.data.title}</div>
{tt.data.lines.map((l, i) => ( {tt.data.lines.map((l, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2, color: sub as string }}> <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2, color: sub as string }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: l.color, flexShrink: 0 }} /> <div style={{ width: 8, height: 8, borderRadius: '50%', background: l.color, flexShrink: 0 }} />
@@ -142,7 +142,7 @@ function ChartPanel({ title, summary, accentColor, flex = '1 1 100%', headerRigh
}}> }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>{title}</Typography> <Typography variant="subtitle1">{title}</Typography>
{summary ? <Typography variant="caption" color="text.secondary">{summary}</Typography> : null} {summary ? <Typography variant="caption" color="text.secondary">{summary}</Typography> : null}
</Box> </Box>
{headerRight ?? null} {headerRight ?? null}
@@ -163,7 +163,7 @@ function KpiTile({ label, value, color }: { label: string; value: number; color:
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5,
boxShadow: '0 1px 8px rgba(0,0,0,0.07)', boxShadow: '0 1px 8px rgba(0,0,0,0.07)',
}}> }}>
<Typography sx={{ fontSize: 34, fontWeight: 700, lineHeight: 1.1, color: accentColor }}>{value}</Typography> <Typography sx={{ fontSize: 34, fontWeight: 400, lineHeight: 1.1, color: accentColor }}>{value}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', lineHeight: 1.3 }}>{label}</Typography> <Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', lineHeight: 1.3 }}>{label}</Typography>
</Paper> </Paper>
) )
@@ -214,7 +214,7 @@ function StatusDonutChart({ data, total }: { data: StatusDatum[]; total: number
))} ))}
</Pie> </Pie>
)} )}
<text textAnchor="middle" dy="-0.1em" style={{ fontSize: 28, fontWeight: 700, fill: labelColor as string }}>{total}</text> <text textAnchor="middle" dy="-0.1em" style={{ fontSize: 28, fontWeight: 400, fill: labelColor as string }}>{total}</text>
<text textAnchor="middle" dy="1.35em" style={{ fontSize: 11, fill: subLabelColor }}>cards</text> <text textAnchor="middle" dy="1.35em" style={{ fontSize: 11, fill: subLabelColor }}>cards</text>
</Group> </Group>
</svg> </svg>
@@ -223,7 +223,7 @@ function StatusDonutChart({ data, total }: { data: StatusDatum[]; total: number
<Box key={d.value} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box key={d.value} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: d.color, flexShrink: 0 }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: d.color, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, color: 'text.secondary' }}>{d.label}</Typography> <Typography variant="caption" sx={{ flex: 1, color: 'text.secondary' }}>{d.label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>{d.count}</Typography> <Typography variant="caption" sx={{ fontWeight: 400 }}>{d.count}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -428,7 +428,7 @@ function OpenCardAgeChart({ width, data }: { width: number; data: AgeBucket[] })
style={{ cursor: 'default' }} style={{ cursor: 'default' }}
/> />
{d.count > 0 ? ( {d.count > 0 ? (
<text x={bw + 7} y={y + bh / 2 + 4} fontSize={12} fontWeight={600} fill={d.color}>{d.count}</text> <text x={bw + 7} y={y + bh / 2 + 4} fontSize={12} fontWeight={400} fill={d.color}>{d.count}</text>
) : ( ) : (
<text x={7} y={y + bh / 2 + 4} fontSize={11} fill={labelColor}>0</text> <text x={7} y={y + bh / 2 + 4} fontSize={11} fill={labelColor}>0</text>
)} )}
@@ -514,7 +514,7 @@ function AssigneeWorkloadChart({ width, data }: { width: number; data: AssigneeD
return ( return (
<g key={d.name}> <g key={d.name}>
<circle cx={avatarCx} cy={cy} r={avatarR} fill={aColor} opacity={0.9} /> <circle cx={avatarCx} cy={cy} r={avatarR} fill={aColor} opacity={0.9} />
<text x={avatarCx} y={cy + 4} textAnchor="middle" fontSize={avatarR > 10 ? 10 : 9} fill="#fff" fontWeight={700}> <text x={avatarCx} y={cy + 4} textAnchor="middle" fontSize={avatarR > 10 ? 10 : 9} fill="#fff" fontWeight={400}>
{getInitials(d.name)} {getInitials(d.name)}
</text> </text>
<text x={avatarCx + avatarR + 6} y={cy + 4} textAnchor="start" fontSize={11} fill={labelColor}> <text x={avatarCx + avatarR + 6} y={cy + 4} textAnchor="start" fontSize={11} fill={labelColor}>
@@ -543,7 +543,7 @@ function AssigneeWorkloadChart({ width, data }: { width: number; data: AssigneeD
xLeft += bw xLeft += bw
return el return el
})} })}
<text x={xLeft + 7} y={cy + 4} fontSize={11} fontWeight={600} fill={labelColor}>{d.total}</text> <text x={xLeft + 7} y={cy + 4} fontSize={11} fontWeight={400} fill={labelColor}>{d.total}</text>
</g> </g>
) )
})} })}
@@ -165,7 +165,7 @@ function SortableRow({
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: v.color, flexShrink: 0 }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: v.color, flexShrink: 0 }} />
) : null} ) : null}
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{v.label}</Typography> <Typography variant="body2" sx={{ fontWeight: 300 }}>{v.label}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>{v.value}</Typography> <Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>{v.value}</Typography>
{showDates(field) && (v.start_date || v.end_date) ? ( {showDates(field) && (v.start_date || v.end_date) ? (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
+2 -2
View File
@@ -113,7 +113,7 @@ function ColumnOverlay({ col, cardCount }: { col: ColDef; cardCount: number }) {
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, cursor: 'grabbing' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, cursor: 'grabbing' }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: col.dotColor, flexShrink: 0 }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: col.dotColor, flexShrink: 0 }} />
<Typography variant="subtitle2" sx={{ flex: 1, fontWeight: 600 }}>{col.label}</Typography> <Typography variant="subtitle2" sx={{ flex: 1 }}>{col.label}</Typography>
<Chip label={cardCount} size="small" /> <Chip label={cardCount} size="small" />
</Box> </Box>
</Box> </Box>
@@ -184,7 +184,7 @@ function KanbanColumn({
}} }}
> >
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: col.dotColor, flexShrink: 0 }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: col.dotColor, flexShrink: 0 }} />
<Typography variant="subtitle2" sx={{ flex: 1, fontWeight: 600 }}>{col.label}</Typography> <Typography variant="subtitle2" sx={{ flex: 1 }}>{col.label}</Typography>
<Chip label={cards.length} size="small" /> <Chip label={cards.length} size="small" />
</Box> </Box>
+3 -3
View File
@@ -78,7 +78,7 @@ function buildColumns(
{typeColor ? ( {typeColor ? (
<Box sx={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, bgcolor: typeColor }} /> <Box sx={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, bgcolor: typeColor }} />
) : null} ) : null}
<Typography variant="body2" sx={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: completed ? 'line-through' : 'none' }}> <Typography variant="body2" sx={{ fontWeight: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: completed ? 'line-through' : 'none' }}>
{r.block.title} {r.block.title}
</Typography> </Typography>
{completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null} {completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null}
@@ -96,7 +96,7 @@ function buildColumns(
const label = resolveBoardFieldLabel(r.props.card_type, typeValues, 'None') const label = resolveBoardFieldLabel(r.props.card_type, typeValues, 'None')
const color = resolveBoardFieldColor(r.props.card_type, typeValues) const color = resolveBoardFieldColor(r.props.card_type, typeValues)
return r.props.card_type ? ( return r.props.card_type ? (
<Chip label={label} size="small" sx={color ? { bgcolor: color, color: '#fff', fontWeight: 600 } : undefined} /> <Chip label={label} size="small" sx={color ? { bgcolor: color, color: '#fff', fontWeight: 400 } : undefined} />
) : ( ) : (
<Typography variant="caption" color="text.disabled">-</Typography> <Typography variant="caption" color="text.disabled">-</Typography>
) )
@@ -292,7 +292,7 @@ export default function BoardTableView(props: BoardTableViewProps) {
<IconButton size="small" tabIndex={-1}> <IconButton size="small" tabIndex={-1}>
{isCollapsed ? <ExpandMoreIcon fontSize="small" /> : <ExpandLessIcon fontSize="small" />} {isCollapsed ? <ExpandMoreIcon fontSize="small" /> : <ExpandLessIcon fontSize="small" />}
</IconButton> </IconButton>
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}> <Typography variant="subtitle2" sx={{ flex: 1 }}>
{label} {label}
</Typography> </Typography>
<Chip label={group.rows.length} size="small" /> <Chip label={group.rows.length} size="small" />
+3 -3
View File
@@ -478,7 +478,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
<Chip <Chip
label={typeValues.find((v) => v.value === props_.card_type)?.label ?? props_.card_type} label={typeValues.find((v) => v.value === props_.card_type)?.label ?? props_.card_type}
size="small" size="small"
sx={{ bgcolor: currentTypeColor, color: '#fff', fontWeight: 600, mt: 0.5 }} sx={{ bgcolor: currentTypeColor, color: '#fff', fontWeight: 400, mt: 0.5 }}
/> />
) : null} ) : null}
{isCompleted ? ( {isCompleted ? (
@@ -787,7 +787,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
</Avatar> </Avatar>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}> <Typography variant="caption" sx={{ fontWeight: 400 }}>
{c.created_by_name || c.created_by} {c.created_by_name || c.created_by}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -975,7 +975,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
<Divider sx={{ mt: 'auto' }} /> <Divider sx={{ mt: 'auto' }} />
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="caption" color="error" sx={{ fontWeight: 700 }}> <Typography variant="caption" color="error" sx={{ fontWeight: 400 }}>
Danger zone Danger zone
</Typography> </Typography>
{confirmDelete ? ( {confirmDelete ? (
+3
View File
@@ -25,6 +25,8 @@ interface CodeBlockProps {
showLineNumbers?: boolean showLineNumbers?: boolean
} }
const codeFontFamily: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
export default function CodeBlock({ code = '', language = 'diff', showLineNumbers = false }: CodeBlockProps) { export default function CodeBlock({ code = '', language = 'diff', showLineNumbers = false }: CodeBlockProps) {
const lines = useMemo(() => { const lines = useMemo(() => {
const rawLines = code.replace(/\n$/, '').split('\n') const rawLines = code.replace(/\n$/, '').split('\n')
@@ -47,6 +49,7 @@ export default function CodeBlock({ code = '', language = 'diff', showLineNumber
backgroundColor: 'transparent', backgroundColor: 'transparent',
lineHeight: 1.4, lineHeight: 1.4,
fontSize: '0.85rem', fontSize: '0.85rem',
fontFamily: codeFontFamily,
display: 'grid', display: 'grid',
gridTemplateColumns: showLineNumbers ? 'auto 1fr' : '1fr', gridTemplateColumns: showLineNumbers ? 'auto 1fr' : '1fr',
columnGap: showLineNumbers ? 12 : 0 columnGap: showLineNumbers ? 12 : 0
+1 -1
View File
@@ -171,7 +171,7 @@ export function CommitMessage(props: CommitMessageProps) {
const collapsible = bodyLines > collapseAfterLines const collapsible = bodyLines > collapseAfterLines
return ( return (
<Box sx={{ minWidth: 0 }}> <Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2">
{subject || 'No commit message'} {subject || 'No commit message'}
</Typography> </Typography>
{hasBody ? ( {hasBody ? (
@@ -2,7 +2,19 @@ import ListItemText from '@mui/material/ListItemText'
import type { ListItemTextProps } from '@mui/material/ListItemText' import type { ListItemTextProps } from '@mui/material/ListItemText'
import type { SxProps, Theme } from '@mui/material/styles' import type { SxProps, Theme } from '@mui/material/styles'
const compactSx: SxProps<Theme> = { minWidth: 0, m: 0 } const compactSx: SxProps<Theme> = { minWidth: 0, m: 0,
'& .MuiListItemText-primary': {
//fontSize: '0.875rem',
fontWeight: 300,
},
'& .MuiListItemText-primary .MuiTypography-root': {
fontWeight: 300,
},
'& > .MuiTypography-root:not(.MuiListItemText-secondary)': {
//fontSize: '0.875rem',
fontWeight: 300,
},
}
export default function CompactListItemText(props: ListItemTextProps) { export default function CompactListItemText(props: ListItemTextProps) {
const sx: SxProps<Theme> = (props.sx ? [compactSx, props.sx] : compactSx) as SxProps<Theme> const sx: SxProps<Theme> = (props.sx ? [compactSx, props.sx] : compactSx) as SxProps<Theme>
+4 -4
View File
@@ -27,7 +27,7 @@ function ContextToolbarLink(props: { to: string; children: ReactNode }) {
variant="body2" variant="body2"
sx={{ sx={{
color: 'text.secondary', color: 'text.secondary',
fontWeight: 600, fontWeight: 300,
textDecoration: 'none', textDecoration: 'none',
minWidth: 0, minWidth: 0,
maxWidth: 220, maxWidth: 220,
@@ -72,7 +72,7 @@ export default function ContextToolbar(props: ContextToolbarProps) {
variant="caption" variant="caption"
sx={{ sx={{
color: 'text.disabled', color: 'text.disabled',
fontWeight: 800, fontWeight: 400,
letterSpacing: 0.8, letterSpacing: 0.8,
textTransform: 'uppercase', textTransform: 'uppercase',
flexShrink: 0, flexShrink: 0,
@@ -91,7 +91,7 @@ export default function ContextToolbar(props: ContextToolbarProps) {
{item.label} {item.label}
</ContextToolbarLink> </ContextToolbarLink>
) : ( ) : (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}> <Typography variant="body2" sx={{ fontWeight: 300, color: 'text.secondary' }}>
{item.label} {item.label}
</Typography> </Typography>
)} )}
@@ -105,7 +105,7 @@ export default function ContextToolbar(props: ContextToolbarProps) {
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
fontWeight: 750, fontWeight: 400,
color: 'text.primary', color: 'text.primary',
minWidth: 0, minWidth: 0,
maxWidth: 340, maxWidth: 340,
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import Popover from '@mui/material/Popover'
import TextField from '@mui/material/TextField'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import Box from '@mui/material/Box'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import Typography from '@mui/material/Typography'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import ManageSearchIcon from '@mui/icons-material/ManageSearch'
import { api } from '../api'
type GoToFileProps = {
repoId: string
refName: string
onSelect: (path: string) => void
}
type Scored = { path: string; indices: number[]; score: number }
// fuzzy does a case-insensitive subsequence match of query against path and
// returns the matched character indices plus a gap-based score (lower = the
// matched characters are closer together). Returns null when not all query
// characters are found in order.
function fuzzy(path: string, query: string): { indices: number[]; score: number } | null {
const p = path.toLowerCase()
const q = query.toLowerCase()
const indices: number[] = []
let qi = 0
let last = -1
let score = 0
for (let i = 0; i < p.length && qi < q.length; i += 1) {
if (p[i] === q[qi]) {
indices.push(i)
if (last >= 0) score += i - last - 1
last = i
qi += 1
}
}
if (qi < q.length) return null
return { indices, score }
}
// GoToFile is a Gitea/GitHub-style file finder presented as a toolbar icon that
// opens a popover (press "t" to open). It lazily loads the repository's full
// file list for the given ref (once, cached until the ref changes) and offers a
// fuzzy-matched, keyboard-navigable list of paths.
export default function GoToFile({ repoId, refName, onSelect }: GoToFileProps) {
const [open, setOpen] = useState(false)
const [files, setFiles] = useState<string[]>([])
const [loadedRef, setLoadedRef] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const buttonRef = useRef<HTMLButtonElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const activeItemRef = useRef<HTMLDivElement | null>(null)
const ensureLoaded = (): void => {
if (loading) return
if (loadedRef === refName) return
setLoading(true)
api.listRepoFiles(repoId, refName || undefined)
.then((list) => {
setFiles(Array.isArray(list) ? list : [])
setLoadedRef(refName)
})
.catch(() => setFiles([]))
.finally(() => setLoading(false))
}
// Invalidate the cached list when the ref changes; it reloads on next open.
useEffect(() => {
if (loadedRef !== null && loadedRef !== refName) {
setLoadedRef(null)
setFiles([])
}
}, [refName, loadedRef])
const handleOpen = (): void => {
ensureLoaded()
setOpen(true)
}
// "t" opens the finder (when not already typing in a field), like Gitea.
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.key !== 't' || event.metaKey || event.ctrlKey || event.altKey) return
const el = document.activeElement as HTMLElement | null
const tag = el ? el.tagName.toLowerCase() : ''
if (tag === 'input' || tag === 'textarea' || (el && el.isContentEditable)) return
event.preventDefault()
handleOpen()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [repoId, refName, loadedRef, loading])
const scored: Scored[] = useMemo(() => {
const q = query.trim()
if (!q) return []
const out: Scored[] = []
let i = 0
for (i = 0; i < files.length; i += 1) {
const m = fuzzy(files[i], q)
if (m) out.push({ path: files[i], indices: m.indices, score: m.score })
}
out.sort((a, b) => (a.score !== b.score ? a.score - b.score : a.path.length - b.path.length))
return out.slice(0, 50)
}, [files, query])
useEffect(() => { setActiveIndex(0) }, [query, open])
useEffect(() => {
if (activeItemRef.current) activeItemRef.current.scrollIntoView({ block: 'nearest' })
}, [activeIndex])
const choose = (path: string): void => {
onSelect(path)
setOpen(false)
setQuery('')
}
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((i) => Math.min(i + 1, Math.max(0, scored.length - 1)))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
if (scored[activeIndex]) choose(scored[activeIndex].path)
} else if (event.key === 'Escape') {
event.preventDefault()
setOpen(false)
}
}
const renderHighlighted = (path: string, indices: number[]) => {
const matched = new Set(indices)
return path.split('').map((ch, i) => (
matched.has(i)
? <Box key={i} component="span" sx={{ fontWeight: 700, color: 'primary.main' }}>{ch}</Box>
: <span key={i}>{ch}</span>
))
}
return (
<>
<Tooltip title="Find file (t)">
<IconButton ref={buttonRef} size="small" onClick={handleOpen} aria-label="Find file">
<ManageSearchIcon fontSize="small" />
</IconButton>
</Tooltip>
<Popover
open={open}
anchorEl={buttonRef.current}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 460, maxWidth: '90vw', p: 1 } } }}
TransitionProps={{ onEntered: () => { if (inputRef.current) inputRef.current.focus() } }}
>
<TextField
inputRef={inputRef}
size="small"
fullWidth
autoFocus
placeholder="Go to file…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onInputKeyDown}
/>
<Box sx={{ mt: 1, maxHeight: 360, overflowY: 'auto' }}>
{loading ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 1 }}>Loading files</Typography>
) : !query.trim() ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 1 }}>Type to search files in this branch.</Typography>
) : scored.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 1 }}>No matching files.</Typography>
) : (
<List dense disablePadding>
{scored.map((s, idx) => (
<ListItemButton
key={s.path}
ref={idx === activeIndex ? activeItemRef : undefined}
selected={idx === activeIndex}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => choose(s.path)}
sx={{ py: 0.25, gap: 1 }}
>
<InsertDriveFileIcon fontSize="small" color="info" />
<Box component="span" sx={{ fontFamily: 'monospace', fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{renderHighlighted(s.path, s.indices)}
</Box>
</ListItemButton>
))}
</List>
)}
</Box>
</Popover>
</>
)
}
+2 -1
View File
@@ -8,6 +8,7 @@ type LazyCodeBlockProps = {
} }
const CodeBlock = lazy(() => import('./CodeBlock')) const CodeBlock = lazy(() => import('./CodeBlock'))
const codeFontFamily: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
export default function LazyCodeBlock(props: LazyCodeBlockProps) { export default function LazyCodeBlock(props: LazyCodeBlockProps) {
const { code = '' } = props const { code = '' } = props
@@ -25,7 +26,7 @@ export default function LazyCodeBlock(props: LazyCodeBlockProps) {
width: '100%', width: '100%',
maxWidth: '100%', maxWidth: '100%',
minWidth: 0, minWidth: 0,
//fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace' fontFamily: codeFontFamily
}} }}
> >
<code>{code}</code> <code>{code}</code>
+75
View File
@@ -0,0 +1,75 @@
import Button from '@mui/material/Button'
import { SxProps, Theme } from '@mui/material/styles'
import { type MouseEvent, type ReactNode } from 'react'
import { Link as RouterLink } from 'react-router-dom'
type PillNavButtonProps = {
children: ReactNode
icon?: ReactNode
to?: string
onClick?: (event: MouseEvent<HTMLButtonElement>) => void
active?: boolean
size?: 'small' | 'medium' | 'large'
sx?: SxProps<Theme>
}
export default function PillNavButton(props: PillNavButtonProps) {
const active: boolean = Boolean(props.active)
const baseSx: SxProps<Theme> = {
px: 1.2,
py: 0.7,
borderRadius: 999,
color: (theme: Theme) => {
if (active) return theme.palette.primary.contrastText
return theme.palette.mode === 'dark' ? 'rgba(226,232,240,0.92)' : theme.palette.text.secondary
},
bgcolor: active ? 'primary.main' : 'transparent',
boxShadow: active ? '0 8px 18px rgba(25, 118, 210, 0.25)' : 'none',
textTransform: 'none',
fontWeight: 300,
lineHeight: 1.2,
transition: 'background-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease',
'&:hover': {
color: (theme: Theme) => active
? theme.palette.primary.contrastText
: theme.palette.mode === 'dark' ? '#ffffff' : theme.palette.text.primary,
bgcolor: (theme: Theme) => {
if (active) return theme.palette.primary.main
return theme.palette.mode === 'dark' ? 'rgba(148,163,184,0.18)' : theme.palette.action.hover
},
},
'& .MuiButton-startIcon': {
mr: 0.75,
ml: 0,
},
'& svg': {
fontSize: 18,
color: 'inherit',
},
}
const sx: SxProps<Theme> = [baseSx, ...(Array.isArray(props.sx) ? props.sx : props.sx ? [props.sx] : [])]
if (props.to) {
return (
<Button
size={props.size || 'small'}
component={RouterLink}
to={props.to}
startIcon={props.icon}
sx={sx}
>
{props.children}
</Button>
)
}
return (
<Button
size={props.size || 'small'}
onClick={props.onClick}
startIcon={props.icon}
sx={sx}
>
{props.children}
</Button>
)
}
+5 -5
View File
@@ -64,7 +64,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
alignItems: 'center', alignItems: 'center',
gap: 0.75, gap: 0.75,
px: 1.2, px: 1.2,
py: 0.7, py: 0.6,
borderRadius: 999, borderRadius: 999,
color: (theme: Theme) => { color: (theme: Theme) => {
if (active) return theme.palette.primary.contrastText if (active) return theme.palette.primary.contrastText
@@ -120,7 +120,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
variant="caption" variant="caption"
sx={{ sx={{
color: 'text.disabled', color: 'text.disabled',
fontWeight: 800, fontWeight: 400,
letterSpacing: 0.8, letterSpacing: 0.8,
textTransform: 'uppercase', textTransform: 'uppercase',
flexShrink: 0, flexShrink: 0,
@@ -132,7 +132,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
className="project-name" className="project-name"
variant="subtitle1" variant="subtitle1"
sx={{ sx={{
fontWeight: 750, fontWeight: 400,
minWidth: 0, minWidth: 0,
maxWidth: 260, maxWidth: 260,
overflow: 'hidden', overflow: 'hidden',
@@ -144,7 +144,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
</Typography> </Typography>
</Box> </Box>
) : null} ) : null}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap', minWidth: 0, py: 0.6 }}>
{navItems.map((item: ProjectNavItem) => { {navItems.map((item: ProjectNavItem) => {
const active: boolean = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`) const active: boolean = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)
return ( return (
@@ -155,7 +155,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
sx={navItemSx(active)} sx={navItemSx(active)}
> >
{item.icon} {item.icon}
<Typography variant="body2" sx={{ fontWeight: active ? 700 : 600, lineHeight: 1.2 }}> <Typography variant="body2" sx={{ fontWeight: active ? 400 : 300, lineHeight: 1.2 }}>
{item.label} {item.label}
</Typography> </Typography>
</Box> </Box>
+1 -1
View File
@@ -27,7 +27,7 @@ function buildProjectColumns(
renderCell: (p) => ( renderCell: (p) => (
<Typography <Typography
component={Link} to={`/projects/${p.id}`} variant="body2" component={Link} to={`/projects/${p.id}`} variant="body2"
sx={{ fontWeight: 500, color: 'primary.main', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }} sx={{ fontWeight: 300, color: 'primary.main', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
onClick={(e: React.MouseEvent) => e.stopPropagation()} onClick={(e: React.MouseEvent) => e.stopPropagation()}
> >
{p.name} {p.name}
+2 -2
View File
@@ -27,8 +27,8 @@ export default function RepoMarkdown(props: RepoMarkdownProps) {
h2: ({ node, ...rest }: any) => <Typography component="h2" variant="h6" sx={{ m: 0 }} {...headingProps(rest)} />, h2: ({ node, ...rest }: any) => <Typography component="h2" variant="h6" sx={{ m: 0 }} {...headingProps(rest)} />,
h3: ({ node, ...rest }: any) => <Typography component="h3" variant="subtitle1" sx={{ m: 0 }} {...headingProps(rest)} />, h3: ({ node, ...rest }: any) => <Typography component="h3" variant="subtitle1" sx={{ m: 0 }} {...headingProps(rest)} />,
h4: ({ node, ...rest }: any) => <Typography component="h4" variant="subtitle2" sx={{ m: 0 }} {...headingProps(rest)} />, h4: ({ node, ...rest }: any) => <Typography component="h4" variant="subtitle2" sx={{ m: 0 }} {...headingProps(rest)} />,
h5: ({ node, ...rest }: any) => <Typography component="h5" variant="body2" sx={{ m: 0, fontWeight: 600 }} {...headingProps(rest)} />, h5: ({ node, ...rest }: any) => <Typography component="h5" variant="body2" sx={{ m: 0, fontWeight: 400 }} {...headingProps(rest)} />,
h6: ({ node, ...rest }: any) => <Typography component="h6" variant="body2" sx={{ m: 0, fontWeight: 600 }} {...headingProps(rest)} />, h6: ({ node, ...rest }: any) => <Typography component="h6" variant="body2" sx={{ m: 0, fontWeight: 400 }} {...headingProps(rest)} />,
pre: ({ children }) => <Box sx={{ m: 0, p: 0, mt: 2, mb: 2 }}>{children}</Box>, pre: ({ children }) => <Box sx={{ m: 0, p: 0, mt: 2, mb: 2 }}>{children}</Box>,
img: ({ src, alt }) => <img src={resolveAsset(src || '')} alt={alt || ''} style={{ maxWidth: '100%' }} />, img: ({ src, alt }) => <img src={resolveAsset(src || '')} alt={alt || ''} style={{ maxWidth: '100%' }} />,
code: ({ className, children, ...rest }: any) => { code: ({ className, children, ...rest }: any) => {
+1 -1
View File
@@ -109,7 +109,7 @@ export default function RepoSubNav(props: RepoSubNavProps) {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
{items.map((item: RepoNavItem) => ( {items.map((item: RepoNavItem) => (
<Box key={item.path} component={Link} to={item.path} sx={itemSx(item.active)}> <Box key={item.path} component={Link} to={item.path} sx={itemSx(item.active)}>
<Typography variant="body2" sx={{ fontWeight: item.active ? 700 : 600, lineHeight: 1.2 }}> <Typography variant="body2" sx={{ fontWeight: item.active ? 400 : 300, lineHeight: 1.2 }}>
{item.label} {item.label}
</Typography> </Typography>
</Box> </Box>
@@ -30,7 +30,7 @@ function typeChip(r: Repo) {
<Chip <Chip
label={r.type || 'git'} label={r.type || 'git'}
size="small" size="small"
sx={{ bgcolor: TYPE_COLORS[r.type || 'git'] ?? '#9e9e9e', color: '#fff', fontWeight: 600, fontSize: 11 }} sx={{ bgcolor: TYPE_COLORS[r.type || 'git'] ?? '#9e9e9e', color: '#fff', fontWeight: 400, fontSize: 11 }}
/> />
) )
} }
@@ -130,7 +130,7 @@ export default function ResizableSortableTable<T,>({
<TableCell <TableCell
key={col.key} key={col.key}
sx={{ sx={{
fontWeight: 600, fontWeight: 400,
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
userSelect: 'none', userSelect: 'none',
+78 -8
View File
@@ -1,21 +1,34 @@
import Box, { BoxProps } from '@mui/material/Box' import Box, { BoxProps } from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import { PointerEvent as ReactPointerEvent, ReactNode, useRef, useState } from 'react' import { PointerEvent as ReactPointerEvent, ReactNode, useRef, useState } from 'react'
type SplitPanelProps = { type SplitPanelProps = {
left: ReactNode left: ReactNode
right: ReactNode right: ReactNode
defaultRatio?: number defaultRatio?: number
collapsible?: boolean
sx?: BoxProps['sx'] sx?: BoxProps['sx']
} }
export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: SplitPanelProps) { type CollapsedSide = 'left' | 'right' | null
export default function SplitPanel({ left, right, defaultRatio = 0.3, collapsible = true, sx }: SplitPanelProps) {
const [splitRatio, setSplitRatio] = useState<number>(defaultRatio) const [splitRatio, setSplitRatio] = useState<number>(defaultRatio)
const [splitDragging, setSplitDragging] = useState<boolean>(false) const [splitDragging, setSplitDragging] = useState<boolean>(false)
const [collapsedSide, setCollapsedSide] = useState<CollapsedSide>(null)
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const gridTemplateColumns: string = collapsedSide === 'left'
? '0 12px minmax(0, 1fr)'
: collapsedSide === 'right'
? 'minmax(0, 1fr) 12px 0'
: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => { const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {
const container: HTMLDivElement | null = containerRef.current const container: HTMLDivElement | null = containerRef.current
if (!splitDragging || !container) return if (!splitDragging || collapsedSide || !container) return
const rect: DOMRect = container.getBoundingClientRect() const rect: DOMRect = container.getBoundingClientRect()
setSplitRatio(Math.max(0.1, Math.min(0.9, (event.clientX - rect.left) / rect.width))) setSplitRatio(Math.max(0.1, Math.min(0.9, (event.clientX - rect.left) / rect.width)))
} }
@@ -24,13 +37,23 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
setSplitDragging(false) setSplitDragging(false)
} }
const toggleCollapsedSide = (side: Exclude<CollapsedSide, null>): void => {
setSplitDragging(false)
setCollapsedSide((current: CollapsedSide) => current === side ? null : side)
}
const handleDividerPointerDown = (): void => {
if (collapsedSide) return
setSplitDragging(true)
}
return ( return (
<Box <Box
ref={containerRef} ref={containerRef}
sx={[ sx={[
{ {
display: 'grid', display: 'grid',
gridTemplateColumns: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`, gridTemplateColumns,
alignItems: 'stretch', alignItems: 'stretch',
height: '100%', height: '100%',
minHeight: 0, minHeight: 0,
@@ -46,7 +69,8 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
> >
<Box <Box
sx={{ sx={{
display: 'flex', gridColumn: '1',
display: collapsedSide === 'left' ? 'none' : 'flex',
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
overflow: 'hidden', overflow: 'hidden',
@@ -60,9 +84,11 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
{left} {left}
</Box> </Box>
<Box <Box
onPointerDown={() => setSplitDragging(true)} onPointerDown={handleDividerPointerDown}
sx={{ sx={{
cursor: 'col-resize', gridColumn: '2',
position: 'relative',
cursor: collapsedSide ? 'default' : 'col-resize',
display: 'grid', display: 'grid',
placeItems: 'stretch', placeItems: 'stretch',
userSelect: 'none', userSelect: 'none',
@@ -76,10 +102,54 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
borderRadius: 999, borderRadius: 999,
}, },
}} }}
/> >
{collapsible ? (
<Box
sx={{
position: 'absolute',
top: 8,
left: '50%',
transform: 'translateX(-50%)',
display: 'grid',
gap: 0.25,
p: 0.25,
border: '1px solid',
borderColor: 'divider',
borderRadius: 999,
bgcolor: 'background.paper',
boxShadow: 1,
zIndex: 1,
}}
>
<Tooltip title={collapsedSide === 'left' ? 'Show left panel' : 'Hide left panel'} placement="right">
<IconButton
size="small"
aria-label={collapsedSide === 'left' ? 'Show left panel' : 'Hide left panel'}
onPointerDown={(event: ReactPointerEvent<HTMLButtonElement>) => event.stopPropagation()}
onClick={() => toggleCollapsedSide('left')}
sx={{ width: 18, height: 18, p: 0 }}
>
{collapsedSide === 'left' ? <ChevronRightIcon sx={{ fontSize: 16 }} /> : <ChevronLeftIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
<Tooltip title={collapsedSide === 'right' ? 'Show right panel' : 'Hide right panel'} placement="right">
<IconButton
size="small"
aria-label={collapsedSide === 'right' ? 'Show right panel' : 'Hide right panel'}
onPointerDown={(event: ReactPointerEvent<HTMLButtonElement>) => event.stopPropagation()}
onClick={() => toggleCollapsedSide('right')}
sx={{ width: 18, height: 18, p: 0 }}
>
{collapsedSide === 'right' ? <ChevronLeftIcon sx={{ fontSize: 16 }} /> : <ChevronRightIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
</Box>
) : null}
</Box>
<Box <Box
sx={{ sx={{
display: 'flex', gridColumn: '3',
display: collapsedSide === 'right' ? 'none' : 'flex',
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
overflow: 'hidden', overflow: 'hidden',
+104 -5
View File
@@ -30,6 +30,7 @@ import { formatEpochTime } from '../time'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import PaginationControls from '../components/PaginationControls'
function fmt(ts: number): string { function fmt(ts: number): string {
if (!ts || ts <= 0) { if (!ts || ts <= 0) {
@@ -93,6 +94,10 @@ export default function AdminPKIPage() {
const [selectedCA, setSelectedCA] = useState('') const [selectedCA, setSelectedCA] = useState('')
const [caQuery, setCAQuery] = useState('') const [caQuery, setCAQuery] = useState('')
const [certQuery, setCertQuery] = useState('') const [certQuery, setCertQuery] = useState('')
const [acmeProfileQuery, setACMEProfileQuery] = useState('')
const [acmeOrderQuery, setACMEOrderQuery] = useState('')
const [certPage, setCertPage] = useState<number>(0)
const [certPageSize, setCertPageSize] = useState<number>(25)
const [certActorFilter, setCertActorFilter] = useState<string[]>([]) const [certActorFilter, setCertActorFilter] = useState<string[]>([])
const [certSourceFilter, setCertSourceFilter] = useState('') const [certSourceFilter, setCertSourceFilter] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -658,6 +663,57 @@ export default function AdminPKIPage() {
].some((value) => value.toLowerCase().includes(q)) ].some((value) => value.toLowerCase().includes(q))
}) })
const paginatedCerts: PKICert[] = filteredCerts.slice(certPage * certPageSize, (certPage * certPageSize) + certPageSize)
useEffect(() => {
const maxPage: number = filteredCerts.length > 0 ? Math.floor((filteredCerts.length - 1) / certPageSize) : 0
if (certPage > maxPage) {
setCertPage(maxPage)
}
}, [filteredCerts.length, certPage, certPageSize])
useEffect(() => {
setCertPage(0)
}, [certQuery, certActorFilter, certSourceFilter, selectedCA])
const filteredACMEProfiles: ACMEProfile[] = acmeProfiles.filter((profile: ACMEProfile) => {
const q: string = acmeProfileQuery.trim().toLowerCase()
if (!q) {
return true
}
return [
profile.name,
profile.id,
profile.directory_url,
profile.email || '',
profile.account_url || '',
profile.solver_type || '',
profile.acme_dns_api_url || '',
profile.acme_dns_user || '',
profile.acme_dns_subdomain || '',
profile.acme_dns_full_domain || '',
profile.last_error || ''
].some((value: string) => value.toLowerCase().includes(q))
})
const filteredACMEOrders: ACMEOrder[] = acmeOrders.filter((order: ACMEOrder) => {
const q: string = acmeOrderQuery.trim().toLowerCase()
if (!q) {
return true
}
return [
order.common_name,
order.id,
order.profile_id,
order.san_dns || '',
order.order_url || '',
order.finalize_url || '',
order.status || '',
order.last_error || '',
order.cert_id || ''
].some((value: string) => value.toLowerCase().includes(q))
})
const certConfirmText = (id: string): string => { const certConfirmText = (id: string): string => {
const cert: PKICert | undefined = certs.find((item) => item.id === id) const cert: PKICert | undefined = certs.find((item) => item.id === id)
if (!cert) return id if (!cert) return id
@@ -863,7 +919,7 @@ export default function AdminPKIPage() {
} }
> >
<List> <List>
{filteredCerts.map((cert) => ( {paginatedCerts.map((cert: PKICert) => (
<ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText <CompactListItemText
@@ -919,12 +975,33 @@ export default function AdminPKIPage() {
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
<PaginationControls
offset={certPage * certPageSize}
limit={certPageSize}
total={filteredCerts.length}
loading={false}
onOffsetChange={(offset: number) => setCertPage(Math.floor(offset / certPageSize))}
onLimitChange={(limit: number) => { setCertPageSize(limit); setCertPage(0) }}
sx={{ mt: 1 }}
/>
</SectionCard> </SectionCard>
<SectionCard <SectionCard
collapsible collapsible
collapseStorageKey="admin-pki:acme-profiles" collapseStorageKey="admin-pki:acme-profiles"
title={`ACME Profiles (${acmeProfiles.length})`} title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
<Typography variant="h6">ACME Profiles ({filteredACMEProfiles.length})</Typography>
<TextField
size="small"
label="Search"
placeholder="Name, id, directory, email"
value={acmeProfileQuery}
onChange={(event) => setACMEProfileQuery(event.target.value)}
sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
/>
</Box>
}
actions={ actions={
<HeaderActionButton variant="outlined" onClick={openNewACME}> <HeaderActionButton variant="outlined" onClick={openNewACME}>
New ACME Profile New ACME Profile
@@ -932,7 +1009,7 @@ export default function AdminPKIPage() {
} }
> >
<List> <List>
{acmeProfiles.map((item) => ( {filteredACMEProfiles.map((item: ACMEProfile) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText <CompactListItemText
@@ -972,6 +1049,11 @@ export default function AdminPKIPage() {
</Box> </Box>
</ListItem> </ListItem>
))} ))}
{filteredACMEProfiles.length === 0 ? (
<ListItem>
<CompactListItemText primary="No ACME profiles found." />
</ListItem>
) : null}
</List> </List>
</SectionCard> </SectionCard>
</Box> </Box>
@@ -1028,7 +1110,19 @@ export default function AdminPKIPage() {
<SectionCard <SectionCard
collapsible collapsible
collapseStorageKey="admin-pki:acme-orders" collapseStorageKey="admin-pki:acme-orders"
title={`ACME Orders (DNS-01) (${acmeOrders.length})`} title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
<Typography variant="h6">ACME Orders (DNS-01) ({filteredACMEOrders.length})</Typography>
<TextField
size="small"
label="Search"
placeholder="CN, id, profile, status"
value={acmeOrderQuery}
onChange={(event) => setACMEOrderQuery(event.target.value)}
sx={{ minWidth: 220, maxWidth: 360, flex: 1 }}
/>
</Box>
}
actions={ actions={
<HeaderActionButton variant="outlined" onClick={openNewACMEOrder} disabled={acmeProfiles.length === 0}> <HeaderActionButton variant="outlined" onClick={openNewACMEOrder} disabled={acmeProfiles.length === 0}>
New ACME Order New ACME Order
@@ -1036,7 +1130,7 @@ export default function AdminPKIPage() {
} }
> >
<List> <List>
{acmeOrders.map((item) => ( {filteredACMEOrders.map((item: ACMEOrder) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText <CompactListItemText
@@ -1077,6 +1171,11 @@ export default function AdminPKIPage() {
</Box> </Box>
</ListItem> </ListItem>
))} ))}
{filteredACMEOrders.length === 0 ? (
<ListItem>
<CompactListItemText primary="No ACME orders found." />
</ListItem>
) : null}
</List> </List>
</SectionCard> </SectionCard>
+1 -1
View File
@@ -409,7 +409,7 @@ export default function BoardPage() {
}} }}
aria-disabled={creatingStandardFieldValues} aria-disabled={creatingStandardFieldValues}
sx={{ sx={{
fontWeight: 700, fontWeight: 400,
cursor: creatingStandardFieldValues ? 'default' : 'pointer', cursor: creatingStandardFieldValues ? 'default' : 'pointer',
pointerEvents: creatingStandardFieldValues ? 'none' : 'auto', pointerEvents: creatingStandardFieldValues ? 'none' : 'auto',
}} }}
+2 -2
View File
@@ -318,7 +318,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
> >
<ListItemText <ListItemText
primary={ primary={
<Typography variant="body2" sx={{ fontWeight: selectedImage === image ? 600 : 400 }}> <Typography variant="body2" sx={{ fontWeight: selectedImage === image ? 400 : 300 }}>
{image || '(root)'} {image || '(root)'}
</Typography> </Typography>
} }
@@ -377,7 +377,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
<ListItemButton onClick={() => handleTagSelect(tag)}> <ListItemButton onClick={() => handleTagSelect(tag)}>
<ListItemText <ListItemText
primary={ primary={
<Typography variant="body2" sx={{ fontWeight: selectedTag?.tag === tag.tag ? 600 : 400 }}> <Typography variant="body2" sx={{ fontWeight: selectedTag?.tag === tag.tag ? 400 : 300 }}>
{tag.tag} {tag.tag}
</Typography> </Typography>
} }
+94 -48
View File
@@ -1,9 +1,9 @@
import { Box, Button, DialogActions, DialogTitle, Divider, IconButton, List, ListItem, ListItemText, Paper, TextField, Typography } from '@mui/material' import { Box, Button, DialogActions, DialogTitle, Divider, IconButton, Paper, TextField, Typography } from '@mui/material'
import Dialog from '../components/ModalDialog' import Dialog from '../components/ModalDialog'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api' import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api'
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta' import { splitCommitMessage } from '../components/CommitMeta'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
@@ -12,6 +12,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent' import FormDialogContent from '../components/FormDialogContent'
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
export default function RepoGitBranchesPage() { export default function RepoGitBranchesPage() {
const { projectId, repoId } = useParams() const { projectId, repoId } = useParams()
@@ -178,6 +179,91 @@ export default function RepoGitBranchesPage() {
} }
} }
const branchColumns: RSTColumn<BranchInfo>[] = [
{
key: 'name',
label: 'Branch',
defaultWidth: 260,
minWidth: 160,
getValue: (branch: BranchInfo) => branch.name,
renderCell: (branch: BranchInfo) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Typography sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{branch.name}
</Typography>
{branch.name === defaultBranch ? (
<CheckCircleOutlineIcon fontSize="small" color="success" />
) : null}
</Box>
)
},
{
key: 'message',
label: 'Latest Commit',
defaultWidth: 360,
minWidth: 180,
getValue: (branch: BranchInfo) => branch.last_message || '',
renderCell: (branch: BranchInfo) => (
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{branch.last_message ? splitCommitMessage(branch.last_message).subject : 'No commits'}
</Typography>
{branch.last_hash ? (
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }} noWrap>
{branch.last_hash.slice(0, 12)}
</Typography>
) : null}
</Box>
)
},
{
key: 'author',
label: 'Author',
defaultWidth: 180,
minWidth: 120,
getValue: (branch: BranchInfo) => branch.last_author || '',
renderCell: (branch: BranchInfo) => (
<Typography variant="body2" noWrap>
{branch.last_author || '-'}
</Typography>
)
},
{
key: 'when',
label: 'Date',
defaultWidth: 180,
minWidth: 120,
getValue: (branch: BranchInfo) => branch.last_when || '',
renderCell: (branch: BranchInfo) => (
<Typography variant="body2" color="text.secondary" noWrap>
{branch.last_when || '-'}
</Typography>
)
},
{
key: 'actions',
label: 'Actions',
sortable: false,
resizable: false,
minWidth: 220,
renderCell: (branch: BranchInfo) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'flex-end', whiteSpace: 'nowrap' }}>
{branch.name !== defaultBranch ? (
<Button size="small" onClick={() => handleSetDefault(branch.name)}>
Set Default
</Button>
) : null}
<IconButton size="small" onClick={() => openRename(branch.name)} aria-label="Rename branch">
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => openDelete(branch.name)} aria-label="Delete branch">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
)
}
]
return ( return (
<ProjectPageFrame <ProjectPageFrame
projectId={projectId ?? ''} projectId={projectId ?? ''}
@@ -235,52 +321,12 @@ export default function RepoGitBranchesPage() {
Loading branches... Loading branches...
</Typography> </Typography>
) : null} ) : null}
<List> <ResizableSortableTable
{branches.map((branch) => ( columns={branchColumns}
<ListItem key={branch.name} divider secondaryAction={ data={branches}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> getRowKey={(branch: BranchInfo) => branch.name}
{branch.name === defaultBranch ? ( defaultSortKey="name"
<CheckCircleOutlineIcon fontSize="small" color="success" /> />
) : (
<Button size="small" onClick={() => handleSetDefault(branch.name)}>
Set Default
</Button>
)}
<IconButton size="small" onClick={() => openRename(branch.name)} aria-label="Rename branch">
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => openDelete(branch.name)} aria-label="Delete branch">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}>
<ListItemText
primary={branch.name}
secondary={
<Box sx={{ display: 'grid', gap: 0.5 }}>
{branch.name === defaultBranch ? (
<Typography variant="body2" color="text.secondary">
Default branch
</Typography>
) : null}
{branch.last_hash ? (
<CommitIdentity author={branch.last_author} when={branch.last_when} hash={branch.last_hash} />
) : (
<Typography variant="body2" color="text.secondary">
No commits
</Typography>
)}
{branch.last_message ? (
<Typography variant="body2" color="text.secondary" noWrap>
{splitCommitMessage(branch.last_message).subject}
</Typography>
) : null}
</Box>
}
/>
</ListItem>
))}
</List>
</Paper> </Paper>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth> <Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create Branch</DialogTitle> <DialogTitle>Create Branch</DialogTitle>
+315 -67
View File
@@ -1,18 +1,37 @@
import { Box, Chip, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Tab, Tabs, Tooltip, Typography } from '@mui/material' import { Box, Chip, Collapse, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Tab, Tabs, Tooltip, Typography } from '@mui/material'
import { useEffect, useState } from 'react' import { type ReactNode, useEffect, useState } from 'react'
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RepoCommitDetail } from '../api' import { api, Project, Repo, RepoCommitDetail } from '../api'
import { CommitIdentity, CommitMessage } from '../components/CommitMeta' import { CommitIdentity, CommitMessage } from '../components/CommitMeta'
import LazyCodeBlock from '../components/LazyCodeBlock'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
import SplitPanel from '../components/SplitPanel' import SplitPanel from '../components/SplitPanel'
import PillNavButton from '../components/PillNavButton'
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline' import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline' import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined' import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline' import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
import ArrowBackIcon from '@mui/icons-material/ArrowBack' import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { alpha, type Theme } from '@mui/material/styles'
type CommitFileDiff = {
path: string
status: string
diff: string
}
type DiffRenderRow = {
text: string
leftNum: number
rightNum: number
isAdd: boolean
isDel: boolean
isHunk: boolean
isMeta: boolean
}
export default function RepoGitCommitDetailPage() { export default function RepoGitCommitDetailPage() {
const { projectId, repoId, hash } = useParams() const { projectId, repoId, hash } = useParams()
@@ -26,6 +45,7 @@ export default function RepoGitCommitDetailPage() {
const [fileDiff, setFileDiff] = useState<string>('') const [fileDiff, setFileDiff] = useState<string>('')
const [selectedFile, setSelectedFile] = useState<string>('') const [selectedFile, setSelectedFile] = useState<string>('')
const [diffView, setDiffView] = useState<'unified' | 'split'>('unified') const [diffView, setDiffView] = useState<'unified' | 'split'>('unified')
const [collapsedDiffs, setCollapsedDiffs] = useState<Record<string, boolean>>({})
useEffect(() => { useEffect(() => {
if (!repoId || !hash) return if (!repoId || !hash) return
@@ -94,93 +114,262 @@ export default function RepoGitCommitDetailPage() {
return '' return ''
} }
const stripDiffFileHeader = (raw: string): string => {
let beforeHunk: boolean
let lines: string[]
let kept: string[]
let i: number
let line: string
beforeHunk = true
lines = (raw || '').replace(/\r\n/g, '\n').split('\n')
kept = []
for (i = 0; i < lines.length; i += 1) {
line = lines[i]
if (line.startsWith('@@')) {
beforeHunk = false
}
if (beforeHunk && (
line.startsWith('diff --git ') ||
line.startsWith('index ') ||
line.startsWith('--- ') ||
line.startsWith('+++ ')
)) {
continue
}
kept.push(line)
}
return kept.join('\n').trimEnd()
}
const buildFileDiffs = (): CommitFileDiff[] => {
let files: { path: string; status: string }[]
let result: CommitFileDiff[]
let i: number
let file: { path: string; status: string }
files = detail?.files || []
result = []
for (i = 0; i < files.length; i += 1) {
file = files[i]
result.push({
path: file.path,
status: file.status,
diff: stripDiffFileHeader(extractFileDiffFromCommitDiff(diff, file.path))
})
}
return result
}
const handleFileDiff = (file: string) => { const handleFileDiff = (file: string) => {
const extracted = extractFileDiffFromCommitDiff(diff, file) const extracted = extractFileDiffFromCommitDiff(diff, file)
setSelectedFile(file) setSelectedFile(file)
setFileDiff(extracted) setFileDiff(extracted)
} }
const renderSplitDiff = (raw: string) => { const buildDiffRows = (raw: string): DiffRenderRow[] => {
const lines = raw.replace(/\r\n/g, '\n').split('\n') const lines: string[] = raw.replace(/\r\n/g, '\n').split('\n')
let leftLine = 0 const hunkRe: RegExp = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
let rightLine = 0 const rows: DiffRenderRow[] = []
const hunkRe = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/ let leftLine: number = 0
const rows = lines.map((line) => { let rightLine: number = 0
const prefix = line.charAt(0) let i: number
const isAdd = prefix === '+' let line: string
const isDel = prefix === '-' let prefix: string
const isHunk = prefix === '@' let isAdd: boolean
const isMeta = line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('+++') || line.startsWith('---') let isDel: boolean
let isHunk: boolean
let isMeta: boolean
let leftNum: number
let rightNum: number
let match: RegExpMatchArray | null
for (i = 0; i < lines.length; i += 1) {
line = lines[i]
prefix = line.charAt(0)
isHunk = line.startsWith('@@')
isMeta = line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('+++') || line.startsWith('---')
isAdd = prefix === '+' && !line.startsWith('+++')
isDel = prefix === '-' && !line.startsWith('---')
leftNum = 0
rightNum = 0
if (isHunk) { if (isHunk) {
const match = line.match(hunkRe) match = line.match(hunkRe)
if (match) { if (match) {
leftLine = Number(match[1]) leftLine = Number(match[1])
rightLine = Number(match[2]) rightLine = Number(match[2])
} }
} else if (!isMeta) {
if (isAdd) {
rightNum = rightLine
rightLine += 1
} else if (isDel) {
leftNum = leftLine
leftLine += 1
} else {
leftNum = leftLine
rightNum = rightLine
leftLine += 1
rightLine += 1
}
} }
const showLeftNum = !isAdd && !isMeta rows.push({
const showRightNum = !isDel && !isMeta text: line,
const leftNum = showLeftNum ? leftLine : 0
const rightNum = showRightNum ? rightLine : 0
if (!isMeta && !isHunk) {
if (showLeftNum) leftLine += 1
if (showRightNum) rightLine += 1
}
return {
left: isAdd ? '' : line,
right: isDel ? '' : line,
leftNum, leftNum,
rightNum, rightNum,
isAdd, isAdd,
isDel, isDel,
isHunk, isHunk,
isMeta isMeta
} })
}) }
const monoFont = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' return rows
const renderSide = (side: 'left' | 'right', label: string) => ( }
const renderUnifiedDiff = (raw: string): ReactNode => {
const rows: DiffRenderRow[] = buildDiffRows(raw)
const monoFont: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const contentWidth: string = diffContentWidth(rows, undefined, 12)
return (
<Box sx={{ width: '100%', overflowX: 'auto', fontFamily: monoFont, fontSize: 12 }}> <Box sx={{ width: '100%', overflowX: 'auto', fontFamily: monoFont, fontSize: 12 }}>
<Box sx={{ width: contentWidth }}>
{rows.map((row: DiffRenderRow, idx: number) => {
return (
<Box key={`unified-${idx}`} sx={{ display: 'grid', gridTemplateColumns: '5ch 5ch minmax(0, 1fr)', backgroundColor: (theme: Theme) => diffRowBackground(theme, row) }}>
<Box sx={lineNumberSx}>
{row.leftNum || ''}
</Box>
<Box sx={lineNumberSx}>
{row.rightNum || ''}
</Box>
<Box sx={{ px: 1, py: 0.25, whiteSpace: 'pre', overflow: 'visible' }}>{row.text || ' '}</Box>
</Box>
)
})}
</Box>
</Box>
)
}
const diffContentWidth = (rows: DiffRenderRow[], side: 'left' | 'right' | undefined, extraColumns: number): string => {
let maxColumns: number
let i: number
let row: DiffRenderRow
let text: string
let columns: number
maxColumns = 0
for (i = 0; i < rows.length; i += 1) {
row = rows[i]
if (side === 'left' && row.isAdd) {
text = ''
} else if (side === 'right' && row.isDel) {
text = ''
} else {
text = row.text
}
columns = diffTextColumns(text)
if (columns > maxColumns) {
maxColumns = columns
}
}
return `max(100%, ${maxColumns + extraColumns}ch)`
}
const diffTextColumns = (text: string): number => {
const tabSize: number = 8
let columns: number
let i: number
let ch: string
columns = 0
for (i = 0; i < text.length; i += 1) {
ch = text[i]
if (ch === '\t') {
columns += tabSize - (columns % tabSize)
} else {
columns += 1
}
}
return columns
}
const diffRowBackground = (theme: Theme, row: DiffRenderRow, side?: 'left' | 'right'): string => {
let changed: boolean
let addBackground: boolean
if (row.isHunk) {
return alpha(theme.palette.info.main, theme.palette.mode === 'dark' ? 0.22 : 0.12)
}
if (row.isMeta) {
return alpha(theme.palette.text.secondary, theme.palette.mode === 'dark' ? 0.16 : 0.08)
}
if (side === 'left') {
changed = row.isDel
addBackground = false
} else if (side === 'right') {
changed = row.isAdd
addBackground = true
} else {
changed = row.isAdd || row.isDel
addBackground = row.isAdd
}
if (!changed) {
return 'transparent'
}
return alpha(addBackground ? theme.palette.success.main : theme.palette.error.main, theme.palette.mode === 'dark' ? 0.20 : 0.18)
}
const lineNumberSx = (theme: Theme): object => ({
color: theme.palette.text.secondary,
textAlign: 'right',
userSelect: 'none',
pr: 0.5,
borderRight: `1px solid ${alpha(theme.palette.text.secondary, theme.palette.mode === 'dark' ? 0.28 : 0.22)}`,
backgroundColor: alpha(theme.palette.text.secondary, theme.palette.mode === 'dark' ? 0.10 : 0.06)
})
const renderSplitDiff = (raw: string) => {
const rows: DiffRenderRow[] = buildDiffRows(raw)
const monoFont: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const renderSide = (side: 'left' | 'right', label: string) => {
const contentWidth: string = diffContentWidth(rows, side, 8)
return (
<Box sx={{ width: '100%', overflowX: 'auto', fontFamily: monoFont, fontSize: 12 }}>
<Box sx={{ width: contentWidth }}>
<Box <Box
sx={{ sx={{
px: 1, px: 1,
py: 0.5, py: 0.5,
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'divider', borderColor: 'divider',
fontWeight: 600 fontWeight: 400
}} }}
> >
<Typography variant="caption" sx={{ fontWeight: 700 }}> <Typography variant="caption" sx={{ fontWeight: 400 }}>
{label} {label}
</Typography> </Typography>
</Box> </Box>
{rows.map((row, idx) => { {rows.map((row: DiffRenderRow, idx: number) => {
const text = side === 'left' ? row.left : row.right const text: string = side === 'left' && row.isAdd ? '' : side === 'right' && row.isDel ? '' : row.text
const num = side === 'left' ? row.leftNum : row.rightNum const num: number = side === 'left' ? row.leftNum : row.rightNum
const changed = side === 'left' ? row.isDel : row.isAdd
const changedBg = side === 'left' ? 'rgba(239,68,68,0.08)' : 'rgba(16,185,129,0.08)'
const backgroundColor = changed ? changedBg : row.isHunk || row.isMeta ? 'rgba(59,130,246,0.08)' : 'transparent'
return ( return (
<Box key={`${side}-${idx}`} sx={{ px: 1, py: 0.25, whiteSpace: 'pre', backgroundColor }}> <Box key={`${side}-${idx}`} sx={{ px: 1, py: 0.25, whiteSpace: 'pre', lineHeight: 1.4, backgroundColor: (theme: Theme) => diffRowBackground(theme, row, side) }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '3ch minmax(0, 1fr)', gap: 1 }}> <Box sx={{ display: 'grid', gridTemplateColumns: '3ch minmax(0, 1fr)', gap: 1 }}>
<Box <Box sx={lineNumberSx}>
sx={{
color: '#9ca3af',
textAlign: 'right',
userSelect: 'none',
backgroundColor: 'rgba(148,163,184,0.08)',
borderRight: '1px solid rgba(148,163,184,0.25)',
pr: 0.5
}}
>
{num || ''} {num || ''}
</Box> </Box>
<Box sx={{ minWidth: 0, whiteSpace: 'pre' }}>{text}</Box> <Box sx={{ minWidth: 0, whiteSpace: 'pre' }}>{text || '\u00a0'}</Box>
</Box> </Box>
</Box> </Box>
) )
})} })}
</Box>
</Box> </Box>
) )
}
return ( return (
<SplitPanel <SplitPanel
defaultRatio={0.5} defaultRatio={0.5}
@@ -222,6 +411,74 @@ export default function RepoGitCommitDetailPage() {
return `/projects/${projectId}/repos/${repoId}?ref=${encodeURIComponent(commitHash)}` return `/projects/${projectId}/repos/${repoId}?ref=${encodeURIComponent(commitHash)}`
} }
const toggleFileDiffCollapsed = (filePath: string): void => {
setCollapsedDiffs((prev: Record<string, boolean>) => ({ ...prev, [filePath]: !prev[filePath] }))
}
const renderFileDiffPanel = (item: CommitFileDiff): ReactNode => {
const collapsed: boolean = Boolean(collapsedDiffs[item.path])
return (
<Paper key={item.path} variant="outlined" sx={{ mb: 1.5, overflow: 'hidden', backgroundColor: 'transparent' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.25,
py: 0.75,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'action.hover',
minWidth: 0
}}
>
<Tooltip title={collapsed ? 'Expand file diff' : 'Collapse file diff'}>
<IconButton
size="small"
onClick={() => toggleFileDiffCollapsed(item.path)}
aria-label={collapsed ? 'Expand file diff' : 'Collapse file diff'}
sx={{ flex: '0 0 auto' }}
>
{collapsed ? <ExpandMoreIcon fontSize="small" /> : <ExpandLessIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title={item.status}>
<Box sx={{ display: 'flex', alignItems: 'center', flex: '0 0 auto' }}>{renderStatusIcon(item.status)}</Box>
</Tooltip>
<Tooltip title={item.path}>
<PillNavButton
to={fileCodePath(item.path)}
sx={{ minWidth: 0, maxWidth: '100%', justifyContent: 'flex-start' }}
>
<Box
component="span"
sx={{ display: 'block', minWidth: 0, maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{item.path}
</Box>
</PillNavButton>
</Tooltip>
</Box>
<Collapse in={!collapsed} timeout="auto" unmountOnExit>
<Box sx={{ minWidth: 0 }}>
{item.diff ? (
diffView === 'unified' ? (
renderUnifiedDiff(item.diff)
) : (
renderSplitDiff(item.diff)
)
) : (
<Typography variant="body2" color="text.secondary" sx={{ p: 1.25 }}>
No textual diff available.
</Typography>
)}
</Box>
</Collapse>
</Paper>
)
}
return ( return (
<ProjectPageFrame <ProjectPageFrame
projectId={projectId ?? ''} projectId={projectId ?? ''}
@@ -271,13 +528,8 @@ export default function RepoGitCommitDetailPage() {
</List> </List>
</Paper> </Paper>
)} right={( )} right={(
<Paper sx={{ p: 2, height: '100%', minWidth: 0, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}> <Paper sx={{ p: 2, height: '100%', minWidth: 0, minHeight: 0, overflowY: 'auto', overflowX: 'hidden', ml: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Tooltip title="Back">
<IconButton size="small" onClick={() => navigate(-1)} aria-label="Back">
<ArrowBackIcon fontSize="small" />
</IconButton>
</Tooltip>
<CommitIdentity <CommitIdentity
author={detail?.author} author={detail?.author}
email={detail?.email} email={detail?.email}
@@ -356,17 +608,13 @@ export default function RepoGitCommitDetailPage() {
</Box> </Box>
) : null} ) : null}
{selectedFile ? ( {selectedFile ? (
fileDiff ? ( renderFileDiffPanel({
diffView === 'unified' ? ( path: selectedFile,
<LazyCodeBlock code={fileDiff} language="diff" showLineNumbers /> status: detail?.files.find((file) => file.path === selectedFile)?.status || 'modified',
) : ( diff: stripDiffFileHeader(fileDiff)
renderSplitDiff(fileDiff) })
)
) : (
<Typography>No diff available.</Typography>
)
) : diff ? ( ) : diff ? (
diffView === 'unified' ? <LazyCodeBlock code={diff} language="diff" showLineNumbers /> : renderSplitDiff(diff) buildFileDiffs().map((item: CommitFileDiff) => renderFileDiffPanel(item))
) : ( ) : (
<Typography>No diff available.</Typography> <Typography>No diff available.</Typography>
)} )}
+257 -41
View File
@@ -1,13 +1,18 @@
import { Box, Button, Divider, List, ListItem, ListItemButton, ListItemText, MenuItem, Paper, Select, TextField, Typography } from '@mui/material' import { Box, Button, Divider, IconButton, List, ListItem, ListItemText, MenuItem, Paper, Select, TextField, Tooltip, Typography } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RepoCommit } from '../api' import { api, Project, Repo, RepoCommit } from '../api'
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta' import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
import AccountTreeIcon from '@mui/icons-material/AccountTree' import AccountTreeIcon from '@mui/icons-material/AccountTree'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import FolderOpenIcon from '@mui/icons-material/FolderOpen'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
import { formatCommitTime } from '../time'
export default function RepoGitCommitsPage() { export default function RepoGitCommitsPage() {
const { projectId, repoId } = useParams() const { projectId, repoId } = useParams()
@@ -24,6 +29,9 @@ export default function RepoGitCommitsPage() {
const [query, setQuery] = useState<string>('') const [query, setQuery] = useState<string>('')
const [page, setPage] = useState<number>(0) const [page, setPage] = useState<number>(0)
const [pageSize, setPageSize] = useState<number>(20) const [pageSize, setPageSize] = useState<number>(20)
const [commitsLoading, setCommitsLoading] = useState<boolean>(false)
const [hasNextPage, setHasNextPage] = useState<boolean>(false)
const filePath: string = searchParams.get('path') || ''
useEffect(() => { useEffect(() => {
const urlRef = searchParams.get('ref') || '' const urlRef = searchParams.get('ref') || ''
@@ -77,12 +85,65 @@ export default function RepoGitCommitsPage() {
}, [repoId]) }, [repoId])
useEffect(() => { useEffect(() => {
let cancelled: boolean
const offset: number = page * pageSize
let historyLimit: number
if (!repoId) return if (!repoId) return
const offset = page * pageSize cancelled = false
setCommitsLoading(true)
if (filePath) {
historyLimit = ((page + 1) * pageSize) + 1
api.listRepoFileHistory(repoId, ref || undefined, filePath, historyLimit)
.then((list) => {
const data: RepoCommit[] = Array.isArray(list) ? list : []
if (cancelled) return
setCommits(data.slice(page * pageSize, (page + 1) * pageSize))
setHasNextPage(data.length > (page + 1) * pageSize)
})
.catch(() => {
if (cancelled) return
setCommits([])
setHasNextPage(false)
})
.finally(() => {
if (cancelled) return
setCommitsLoading(false)
})
return () => {
cancelled = true
}
}
api.listRepoCommits(repoId, ref || undefined, pageSize, offset, query) api.listRepoCommits(repoId, ref || undefined, pageSize, offset, query)
.then((list) => setCommits(Array.isArray(list) ? list : [])) .then((list) => {
.catch(() => setCommits([])) if (cancelled) return
}, [repoId, ref, page, pageSize, query]) setCommits(Array.isArray(list) ? list : [])
setHasNextPage(Array.isArray(list) && list.length >= pageSize)
})
.catch(() => {
if (cancelled) return
setCommits([])
setHasNextPage(false)
})
.finally(() => {
if (cancelled) return
setCommitsLoading(false)
})
return () => {
cancelled = true
}
}, [repoId, ref, page, pageSize, query, filePath])
useEffect(() => {
if (!commitsLoading && page > 0 && commits.length === 0) {
setPage((value: number) => Math.max(0, value - 1))
}
}, [commitsLoading, commits.length, page])
useEffect(() => {
setPage(0)
}, [filePath])
const handleCompare = async () => { const handleCompare = async () => {
if (!repoId || !compareBase || !compareHead) return if (!repoId || !compareBase || !compareHead) return
@@ -90,6 +151,152 @@ export default function RepoGitCommitsPage() {
setCompareCommits(Array.isArray(list) ? list : []) setCompareCommits(Array.isArray(list) ? list : [])
} }
const handlePrevPage = () => {
if (commitsLoading || page <= 0) return
setPage((value: number) => Math.max(0, value - 1))
}
const handleNextPage = () => {
if (commitsLoading || !hasNextPage) return
setPage((value: number) => value + 1)
}
const copyText = async (text: string) => {
let textarea: HTMLTextAreaElement
try {
await navigator.clipboard.writeText(text)
return
} catch {
// fallback below
}
textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
}
const commitDetailPath = (commit: RepoCommit): string => {
if (!projectId || !repoId) return '#'
return `/projects/${projectId}/repos/${repoId}/commits/${commit.hash}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`
}
const repoAtCommitPath = (commit: RepoCommit): string => {
if (!projectId || !repoId) return '#'
return `/projects/${projectId}/repos/${repoId}?ref=${encodeURIComponent(commit.hash)}`
}
const fileAtCommitPath = (commit: RepoCommit): string => {
const params: URLSearchParams = new URLSearchParams()
if (!projectId || !repoId || !filePath) return '#'
params.set('ref', commit.hash)
params.set('path', filePath)
return `/projects/${projectId}/repos/${repoId}?${params.toString()}`
}
const commitColumns: RSTColumn<RepoCommit>[] = [
{
key: 'subject',
label: 'Commit',
defaultWidth: 420,
minWidth: 220,
getValue: (commit: RepoCommit) => splitCommitMessage(commit.message).subject || commit.message,
renderCell: (commit: RepoCommit) => (
<Typography
component={Link}
to={commitDetailPath(commit)}
color="primary"
sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: 'none' }}
>
{splitCommitMessage(commit.message).subject || commit.message}
</Typography>
)
},
{
key: 'author',
label: 'Author',
defaultWidth: 180,
minWidth: 120,
getValue: (commit: RepoCommit) => commit.author,
renderCell: (commit: RepoCommit) => (
<Typography variant="body2" noWrap>
{commit.author || '-'}
</Typography>
)
},
{
key: 'when',
label: 'Date',
defaultWidth: 180,
minWidth: 120,
getValue: (commit: RepoCommit) => commit.when,
renderCell: (commit: RepoCommit) => (
<Typography variant="body2" color="text.secondary" noWrap>
{formatCommitTime(commit.when) || '-'}
</Typography>
)
},
{
key: 'hash',
label: 'Hash',
defaultWidth: 160,
minWidth: 110,
getValue: (commit: RepoCommit) => commit.hash,
renderCell: (commit: RepoCommit) => (
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace' }} noWrap>
{commit.hash.slice(0, 12)}
</Typography>
)
},
{
key: 'actions',
label: 'Actions',
sortable: false,
resizable: false,
minWidth: filePath ? 132 : 96,
renderCell: (commit: RepoCommit) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 0.5, whiteSpace: 'nowrap' }}>
<Tooltip title="Copy commit hash">
<IconButton size="small" onClick={() => copyText(commit.hash)} aria-label="Copy commit hash">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Browse repository at this commit">
<IconButton
size="small"
component={Link}
to={repoAtCommitPath(commit)}
aria-label="Browse repository at this commit"
>
<FolderOpenIcon fontSize="small" />
</IconButton>
</Tooltip>
{filePath ? (
<Tooltip title="View file at this commit">
<IconButton
size="small"
component={Link}
to={fileAtCommitPath(commit)}
aria-label="View file at this commit"
>
<InsertDriveFileIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null}
</Box>
)
}
]
return ( return (
<ProjectPageFrame <ProjectPageFrame
projectId={projectId ?? ''} projectId={projectId ?? ''}
@@ -110,7 +317,7 @@ export default function RepoGitCommitsPage() {
</Typography> </Typography>
) : null} ) : null}
<Paper sx={{ p: 2, mb: 1, backgroundColor: 'transparent' }}> <Paper sx={{ p: 2, mb: 1, backgroundColor: 'transparent' }}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center', mb: 2 }}>
<Box sx={{ minWidth: 220, flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ minWidth: 220, flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<AccountTreeIcon /> <AccountTreeIcon />
<Autocomplete <Autocomplete
@@ -127,46 +334,55 @@ export default function RepoGitCommitsPage() {
/> />
</Box> </Box>
<Box sx={{ flex: 1, minWidth: 260, display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ flex: 1, minWidth: 260, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">Commit</Typography> {filePath ? (
<TextField <>
size="small" <Typography variant="subtitle2">File</Typography>
placeholder="Search author or message" <Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
value={query} {filePath}
onChange={(e) => { </Typography>
setQuery(e.target.value) </>
setPage(0) ) : (
}} <>
fullWidth <Typography variant="subtitle2">Commit</Typography>
/> <TextField
<Select size="small" value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}> size="small"
{[10, 20, 50].map((size) => ( placeholder="Search author or message"
<MenuItem key={size} value={size}> value={query}
{size} onChange={(e) => {
</MenuItem> setQuery(e.target.value)
))} setPage(0)
</Select> }}
fullWidth
/>
</>
)}
</Box> </Box>
</Box> </Box>
<List dense> <ResizableSortableTable
{commits.map((commit) => ( columns={commitColumns}
<ListItem key={commit.hash} divider> data={commits}
<ListItemButton getRowKey={(commit: RepoCommit) => commit.hash}
component={Link} defaultSortKey="when"
to={`/projects/${projectId}/repos/${repoId}/commits/${commit.hash}?ref=${encodeURIComponent(ref)}`} defaultSortDir="desc"
> />
<ListItemText <Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1, alignItems: 'center', flexWrap: 'wrap' }}>
primary={splitCommitMessage(commit.message).subject || commit.message} <Typography variant="caption" color="text.secondary">
secondary={<CommitIdentity author={commit.author} email={commit.email} when={commit.when} hash={commit.hash} />} Page {page + 1}
/> </Typography>
</ListItemButton> <Typography variant="caption" color="text.secondary">
</ListItem> Size
</Typography>
<Select size="small" value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(0) }}>
{[10, 20, 50].map((size: number) => (
<MenuItem key={size} value={size}>
{size}
</MenuItem>
))} ))}
</List> </Select>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}> <Button size="small" variant="outlined" disabled={commitsLoading || page === 0} onClick={handlePrevPage}>
<Button size="small" variant="outlined" disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
Prev Prev
</Button> </Button>
<Button size="small" variant="outlined" onClick={() => setPage((p) => p + 1)}> <Button size="small" variant="outlined" disabled={commitsLoading || !hasNextPage} onClick={handleNextPage}>
Next Next
</Button> </Button>
</Box> </Box>
+170 -119
View File
@@ -1,8 +1,9 @@
import { Box, Button, Chip, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Popover, Tab, Tabs, TextField, Tooltip, Typography } from '@mui/material' import { Box, Button, Chip, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Popover, Tab, Tabs, TextField, Tooltip, Typography } from '@mui/material'
import { lazy, Suspense, useEffect, useRef, useState } from 'react' import { lazy, Suspense, useEffect, useRef, useState } from 'react'
import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom' import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RepoCommit, RepoTreeEntry } from '../api' import { api, Project, Repo, RepoCommit, RepoTreeEntry, RepoTreeEntryCommit } from '../api'
import { CommitIdentity, CommitMessage, splitCommitMessage } from '../components/CommitMeta' import { CommitIdentity, CommitMessage, splitCommitMessage } from '../components/CommitMeta'
import { formatCommitTime } from '../time'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import AccountTreeIcon from '@mui/icons-material/AccountTree' import AccountTreeIcon from '@mui/icons-material/AccountTree'
import CodeIcon from '@mui/icons-material/Code' import CodeIcon from '@mui/icons-material/Code'
@@ -11,10 +12,13 @@ import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
import FolderIcon from '@mui/icons-material/Folder' import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile' import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import FileDownloadIcon from '@mui/icons-material/FileDownload' import FileDownloadIcon from '@mui/icons-material/FileDownload'
import HistoryIcon from '@mui/icons-material/History'
import OpenInNewIcon from '@mui/icons-material/OpenInNew' import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import TerminalIcon from '@mui/icons-material/Terminal' import TerminalIcon from '@mui/icons-material/Terminal'
import LazyCodeBlock from '../components/LazyCodeBlock' import LazyCodeBlock from '../components/LazyCodeBlock'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import PillNavButton from '../components/PillNavButton'
import GoToFile from '../components/GoToFile'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
@@ -40,6 +44,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
const [tree, setTree] = useState<RepoTreeEntry[]>([]) const [tree, setTree] = useState<RepoTreeEntry[]>([])
const [treeError, setTreeError] = useState<string | null>(null) const [treeError, setTreeError] = useState<string | null>(null)
const [treeReloadTick, setTreeReloadTick] = useState(0) const [treeReloadTick, setTreeReloadTick] = useState(0)
const [dirEntries, setDirEntries] = useState<RepoTreeEntryCommit[]>([])
const [dirLoading, setDirLoading] = useState(false)
const [fileQuery, setFileQuery] = useState('') const [fileQuery, setFileQuery] = useState('')
const [path, setPath] = useState('') const [path, setPath] = useState('')
const [pathSegments, setPathSegments] = useState<string[]>([]) const [pathSegments, setPathSegments] = useState<string[]>([])
@@ -47,10 +53,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
const [selectedFile, setSelectedFile] = useState<string>('') const [selectedFile, setSelectedFile] = useState<string>('')
const [isImageFileSelected, setIsImageFileSelected] = useState(false) const [isImageFileSelected, setIsImageFileSelected] = useState(false)
const [imagePreviewRef, setImagePreviewRef] = useState('') const [imagePreviewRef, setImagePreviewRef] = useState('')
const [history, setHistory] = useState<RepoCommit[]>([])
const [diff, setDiff] = useState<string>('') const [diff, setDiff] = useState<string>('')
const [diffRange, setDiffRange] = useState<{ from: string; to: string }>({ from: '', to: '' }) const [diffRange, setDiffRange] = useState<{ from: string; to: string }>({ from: '', to: '' })
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content') const [previewTab, setPreviewTab] = useState<'content' | 'diff'>('content')
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null) const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
const [readmeContent, setReadmeContent] = useState<string>('') const [readmeContent, setReadmeContent] = useState<string>('')
const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('') const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('')
@@ -187,12 +192,37 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setContent('') setContent('')
setPreviewTab('content') setPreviewTab('content')
setDiff('') setDiff('')
setHistory([])
setSelectedCommit(null) setSelectedCommit(null)
} }
}) })
}, [repoId, ref, path, branches, treeReloadTick]) }, [repoId, ref, path, branches, treeReloadTick])
// Directory listing with per-entry last-commit info for the right pane
// (shown when no file is selected). One call per directory view.
useEffect(() => {
if (selectedFile) return
if (!repoId) return
if (!ref && branches.length === 0) return
if (!repo) return
if (repo.type && repo.type !== 'git') return
let cancelled = false
setDirLoading(true)
api.listRepoTreeCommits(repoId, ref || undefined, path)
.then((list) => {
if (cancelled) return
setDirEntries(Array.isArray(list) ? list : [])
})
.catch(() => {
if (cancelled) return
setDirEntries([])
})
.finally(() => {
if (cancelled) return
setDirLoading(false)
})
return () => { cancelled = true }
}, [repoId, ref, path, branches, treeReloadTick, selectedFile, repo])
const clearReadme = () => { const clearReadme = () => {
setReadmeContent('') setReadmeContent('')
setReadmeKind('') setReadmeKind('')
@@ -244,7 +274,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
var parentPath: string var parentPath: string
var blob: { content: string } var blob: { content: string }
var list: RepoCommit[] var list: RepoCommit[]
var nextHistory: RepoCommit[] var latestCommit: RepoCommit | null
if (!repoId || !filePath) return if (!repoId || !filePath) return
parentPath = filePath.split('/').slice(0, -1).join('/') parentPath = filePath.split('/').slice(0, -1).join('/')
@@ -267,12 +297,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setContent('') setContent('')
} }
try { try {
list = await api.listRepoFileHistory(repoId, ref || undefined, filePath) list = await api.listRepoFileHistory(repoId, ref || undefined, filePath, 1)
nextHistory = Array.isArray(list) ? list : [] latestCommit = Array.isArray(list) && list.length > 0 ? list[0] : null
setHistory(nextHistory) setSelectedCommit(latestCommit)
setSelectedCommit(nextHistory.length ? nextHistory[0] : null)
} catch { } catch {
setHistory([])
setSelectedCommit(null) setSelectedCommit(null)
} }
} }
@@ -305,7 +333,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setImagePreviewRef('') setImagePreviewRef('')
setPreviewTab('content') setPreviewTab('content')
setDiff('') setDiff('')
setHistory([])
setSelectedCommit(null) setSelectedCommit(null)
}, [repoId, ref, urlRef, urlDirPath, urlFilePath]) }, [repoId, ref, urlRef, urlDirPath, urlFilePath])
@@ -320,7 +347,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setIsImageFileSelected(false) setIsImageFileSelected(false)
setImagePreviewRef('') setImagePreviewRef('')
setPreviewTab('content') setPreviewTab('content')
setHistory([])
setSelectedCommit(null) setSelectedCommit(null)
return return
} }
@@ -339,12 +365,24 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setImagePreviewRef('') setImagePreviewRef('')
setPreviewTab('content') setPreviewTab('content')
setDiff('') setDiff('')
setHistory([])
setSelectedCommit(null) setSelectedCommit(null)
} }
const handleBreadcrumb = (nextPath: string) => { const handleBreadcrumb = (nextPath: string) => {
if (nextPath === path) { if (nextPath === path) {
// Already in this directory. If a file is open, clicking the current
// directory returns to its listing; otherwise just reload the tree.
if (selectedFile) {
setSelectedFile('')
setContent('')
setFileQuery('')
setIsImageFileSelected(false)
setImagePreviewRef('')
setPreviewTab('content')
setDiff('')
setSelectedCommit(null)
return
}
setTreeReloadTick((prev) => prev + 1) setTreeReloadTick((prev) => prev + 1)
return return
} }
@@ -361,7 +399,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setImagePreviewRef('') setImagePreviewRef('')
setPreviewTab('content') setPreviewTab('content')
setDiff('') setDiff('')
setHistory([])
setSelectedCommit(null) setSelectedCommit(null)
} }
@@ -445,39 +482,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
? tree.filter((entry) => matchesFileQuery(entry.name, normalizedQuery)) ? tree.filter((entry) => matchesFileQuery(entry.name, normalizedQuery))
: tree : tree
const handleHistory = async () => {
if (!repoId || !selectedFile) return
const list = await api.listRepoFileHistory(repoId, ref || undefined, selectedFile)
const nextHistory = Array.isArray(list) ? list : []
setHistory(nextHistory)
setSelectedCommit((prev) => prev || (nextHistory.length ? nextHistory[0] : null))
}
const handleHistorySelect = async (commitHash: string) => {
if (!repoId || !selectedFile) return
if (isImageFileSelected) {
setImagePreviewRef(commitHash)
const match = history.find((commit) => commit.hash === commitHash) || null
setSelectedCommit(match)
setPreviewTab('content')
return
}
try {
const blob = await api.getRepoBlob(repoId, commitHash, selectedFile)
setContent(blob.content)
const match = history.find((commit) => commit.hash === commitHash) || null
setSelectedCommit(match)
setPreviewTab('content')
} catch {
// leave content as-is on failure
}
}
// Fetch the diff for the file as it changed in the selected commit (the most // Fetch the diff for the file as it changed in the selected commit (the most
// recent commit touching the file by default, or whatever was picked in the // recent commit touching the file by default). Using the branch ref here would
// History tab). Refetches whenever the selected commit changes while the Diff // diff only the branch tip, which is empty for any file the tip commit did not
// tab is open. Using the branch ref here would diff only the branch tip, which // touch.
// is empty for any file the tip commit did not touch.
useEffect(() => { useEffect(() => {
if (previewTab !== 'diff') return if (previewTab !== 'diff') return
if (!repoId || !selectedFile) return if (!repoId || !selectedFile) return
@@ -606,6 +614,16 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
return `/projects/${projectId}/repos/${repoId}/commits/${hash}${queryRef ? `?ref=${encodeURIComponent(queryRef)}` : ''}` return `/projects/${projectId}/repos/${repoId}/commits/${hash}${queryRef ? `?ref=${encodeURIComponent(queryRef)}` : ''}`
} }
const fileHistoryPath = (filePath: string): string => {
var params: URLSearchParams
if (!projectId || !repoId || !filePath) return '#'
params = new URLSearchParams()
if (ref) params.set('ref', ref)
params.set('path', filePath)
return `/projects/${projectId}/repos/${repoId}/commits?${params.toString()}`
}
const resolveReadmeAsset = (src: string) => { const resolveReadmeAsset = (src: string) => {
if (!src) return '' if (!src) return ''
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) return src if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) return src
@@ -641,9 +659,12 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
if (!repo?.clone_url) return null if (!repo?.clone_url) return null
return ( return (
<> <>
<IconButton onClick={(event) => setCodeAnchor(event.currentTarget)}> <PillNavButton
<CodeIcon /> icon={<CodeIcon fontSize="small" />}
</IconButton> onClick={(event) => setCodeAnchor(event.currentTarget)}
>
Clone
</PillNavButton>
<Popover <Popover
open={Boolean(codeAnchor)} open={Boolean(codeAnchor)}
anchorEl={codeAnchor} anchorEl={codeAnchor}
@@ -739,9 +760,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
})} })}
{selectedFile ? ( {selectedFile ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{pathParts.length > 0? (
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}> <Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/ /
</Typography> </Typography>): null}
<Typography variant="body2" sx={{ px: 0.5 }}> <Typography variant="body2" sx={{ px: 0.5 }}>
{selectedFile.split('/').pop()} {selectedFile.split('/').pop()}
</Typography> </Typography>
@@ -750,6 +772,58 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box>) </Box>)
} }
const renderDirectoryListing = () => {
const sorted = [...dirEntries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
return (
<Paper variant="outlined" sx={{ mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<List dense disablePadding>
{path ? (
<ListItem disablePadding divider>
<ListItemButton onClick={handleBack}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon fontSize="small" color="info" />
<Typography variant="body2">..</Typography>
</Box>
</ListItemButton>
</ListItem>
) : null}
{!dirLoading && sorted.length === 0 ? (
<ListItem>
<Typography variant="body2" color="text.secondary">This directory is empty.</Typography>
</ListItem>
) : null}
{sorted.map((entry) => (
<ListItem key={entry.path} disablePadding divider>
<ListItemButton onClick={() => handleOpen(entry)}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.4fr) minmax(0, 2fr) auto', alignItems: 'center', gap: 2, width: '100%', minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
{entry.type === 'dir' ? (
<FolderIcon fontSize="small" color="info" />
) : (
<InsertDriveFileIcon fontSize="small" color="info" />
)}
<Typography variant="body2" noWrap>{entry.name}</Typography>
</Box>
<Tooltip title={entry.last_message || ''}>
<Typography variant="body2" color="text.secondary" noWrap sx={{ minWidth: 0 }}>
{splitCommitMessage(entry.last_message).subject}
</Typography>
</Tooltip>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{entry.last_when ? formatCommitTime(entry.last_when) : ''}
</Typography>
</Box>
</ListItemButton>
</ListItem>
))}
</List>
</Paper>
)
}
return ( return (
<ProjectPageFrame <ProjectPageFrame
projectId={projectId ?? ''} projectId={projectId ?? ''}
@@ -815,8 +889,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box> </Box>
{path ? ( {path ? (
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton onClick={handleBack}> <ListItemButton onClick={handleBack} sx={{ py: 0.3 }}>
<ListItemText <ListItemText
sx={{ my: 0 }}
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon fontSize="small" color="info" /> <FolderIcon fontSize="small" color="info" />
@@ -834,8 +909,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
}) })
.map((entry) => ( .map((entry) => (
<ListItem key={entry.path} disablePadding> <ListItem key={entry.path} disablePadding>
<ListItemButton onClick={() => handleOpen(entry)}> <ListItemButton onClick={() => handleOpen(entry)} sx={{ py: 0.4 }}>
<ListItemText <ListItemText
sx={{ my: 0 }}
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={entry.type === 'dir' ? 'directory' : 'file'}> <Tooltip title={entry.type === 'dir' ? 'directory' : 'file'}>
@@ -857,21 +933,30 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</List> </List>
</TintedPanel> </TintedPanel>
)} right={( )} right={(
<TintedPanel sx={{ height: '100%', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}> <TintedPanel sx={{ height: '100%', minHeight: 0, overflowY: 'auto', overflowX: 'hidden', ml: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
{renderPathBreadcrumb()} {renderPathBreadcrumb()}
{repoId ? <GoToFile repoId={repoId} refName={ref} onSelect={openFilePath} /> : null}
</Box> </Box>
{selectedFile ? ( {selectedFile ? (
<Box sx={{ mt: 0 }}> <Box sx={{ mt: 0 }}>
<CommitIdentity <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
author={selectedCommit?.author} <CommitIdentity
email={selectedCommit?.email} author={selectedCommit?.author}
when={selectedCommit?.when} email={selectedCommit?.email}
hash={selectedCommit?.hash} when={selectedCommit?.when}
hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined} hash={selectedCommit?.hash}
hashChip hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined}
copyHash hashChip
/> copyHash
/>
<PillNavButton
to={fileHistoryPath(selectedFile)}
icon={<HistoryIcon fontSize="small" />}
>
History
</PillNavButton>
</Box>
<Box sx={{ mt: 0.5 }}> <Box sx={{ mt: 0.5 }}>
<CommitMessage message={selectedCommit?.message || ''} inline /> <CommitMessage message={selectedCommit?.message || ''} inline />
</Box> </Box>
@@ -879,13 +964,11 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Tabs <Tabs
value={previewTab} value={previewTab}
onChange={(_, value) => { onChange={(_, value: 'content' | 'diff') => {
setPreviewTab(value) setPreviewTab(value)
if (value === 'history' && history.length === 0) handleHistory()
}} }}
> >
<Tab label="Code" value="content" /> <Tab label="Code" value="content" />
<Tab label="History" value="history" />
<Tab label="Diff" value="diff" /> <Tab label="Diff" value="diff" />
</Tabs> </Tabs>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -966,36 +1049,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Paper> </Paper>
) )
) : null} ) : null}
{previewTab === 'history' ? (
<Box sx={{ mt: 1 }}>
{history.length ? (
<List dense>
{history.map((commit) => (
<ListItem key={commit.hash} divider disablePadding>
<ListItemButton onClick={() => handleHistorySelect(commit.hash)}>
<ListItemText
primary={splitCommitMessage(commit.message).subject || commit.message}
secondary={
<CommitIdentity
author={commit.author}
email={commit.email}
when={commit.when}
hash={commit.hash}
hashHref={commitDetailPath(commit.hash)}
/>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary">
No history available.
</Typography>
)}
</Box>
) : null}
{previewTab === 'diff' ? ( {previewTab === 'diff' ? (
diff ? ( diff ? (
<> <>
@@ -1031,30 +1084,28 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
) )
) : null} ) : null}
</Box> </Box>
) : readmeContent ? (
readmeKind === 'markdown' ? (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Box>
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
<RepoMarkdown
content={readmeContent}
resolveAsset={resolveReadmeAsset}
extractLanguage={extractMarkdownLanguage}
/>
</Suspense>
</Box>
</Paper>
) : (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<LazyCodeBlock code={readmeContent} language="text" showLineNumbers />
</Paper>
)
) : ( ) : (
<Box> <Box sx={{ mt: 0 }}>
<Typography variant="body2" color="text.secondary"> {renderDirectoryListing()}
Select a file to preview its contents. {readmeContent ? (
</Typography> readmeKind === 'markdown' ? (
<Divider sx={{ mt: 0.5, mb: 0.5 }} /> <Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Box>
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
<RepoMarkdown
content={readmeContent}
resolveAsset={resolveReadmeAsset}
extractLanguage={extractMarkdownLanguage}
/>
</Suspense>
</Box>
</Paper>
) : (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<LazyCodeBlock code={readmeContent} language="text" showLineNumbers />
</Paper>
)
) : null}
</Box> </Box>
)} )}
</TintedPanel> </TintedPanel>