Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac72bd3c7c | |||
| db2e0f6537 | |||
| b841cd00d7 | |||
| a7eb5dd636 | |||
| a353bb51ba | |||
| e56ea9c03e | |||
| 4f390ad1d5 | |||
| 98f685e2ed | |||
| eefe3eaf80 | |||
| 7b8d4832aa | |||
| 9a5f12c21c | |||
| 809c4ff038 | |||
| 15b7277204 | |||
| a43cc34d74 | |||
| 83c5d5a0d8 | |||
| 599dbacf48 | |||
| b5f84ce6f5 | |||
| 6f08eed52c |
@@ -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 tu ON t.target_type = 'user' AND tu.id = t.target_id
|
||||
WHERE p.enabled = 1
|
||||
AND ca.status = 'active'
|
||||
AND ca.status = ?
|
||||
AND (
|
||||
(t.target_type = 'user' AND tu.public_id = ?)
|
||||
OR
|
||||
(t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
|
||||
)
|
||||
)
|
||||
ORDER BY p.name`,
|
||||
models.PKIStatusActive,
|
||||
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
|
||||
WHERE p.public_id = ?
|
||||
AND p.enabled = 1
|
||||
AND ca.status = 'active'
|
||||
AND ca.status = ?
|
||||
AND (
|
||||
(t.target_type = 'user' AND tu.public_id = ?)
|
||||
OR
|
||||
(t.target_type = 'group' AND ug.disabled = 0 AND (ug.scope = 'all_users' OR gu.public_id = ?))
|
||||
)`,
|
||||
strings.TrimSpace(profileID),
|
||||
models.PKIStatusActive,
|
||||
strings.TrimSpace(userID),
|
||||
strings.TrimSpace(userID),
|
||||
)
|
||||
@@ -521,7 +523,7 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
|
||||
i.authz_scope,
|
||||
i.not_before,
|
||||
i.not_after,
|
||||
COALESCE(c.status, 'deleted'),
|
||||
COALESCE(c.status, ?),
|
||||
COALESCE(c.revoked_at, 0),
|
||||
COALESCE(c.revocation_reason, ''),
|
||||
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 pki_client_profiles p ON p.id = i.profile_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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *Store) CreatePKICA(item models.PKICA) (models.PKICA, error) {
|
||||
item.SerialCounter = 1
|
||||
}
|
||||
if item.Status == "" {
|
||||
item.Status = "active"
|
||||
item.Status = models.PKIStatusActive
|
||||
}
|
||||
now = time.Now().UTC().Unix()
|
||||
item.CreatedAt = now
|
||||
@@ -309,7 +309,7 @@ func (s *Store) CreatePKICert(item models.PKICert) (models.PKICert, error) {
|
||||
item.ID = id
|
||||
}
|
||||
if item.Status == "" {
|
||||
item.Status = "active"
|
||||
item.Status = models.PKIStatusActive
|
||||
}
|
||||
now = time.Now().UTC().Unix()
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ type TreeEntry struct {
|
||||
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")
|
||||
|
||||
const timeFormat = "2006-01-02 15:04:05Z"
|
||||
@@ -680,6 +691,299 @@ func ListTree(repoPath, ref, path string) ([]TreeEntry, error) {
|
||||
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) {
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
@@ -636,7 +636,7 @@ func (api *API) FinalizeACMEOrder(w http.ResponseWriter, r *http.Request, params
|
||||
KeyPEM: order.KeyPEM,
|
||||
NotBefore: leaf.NotBefore.Unix(),
|
||||
NotAfter: leaf.NotAfter.Unix(),
|
||||
Status: "active",
|
||||
Status: models.PKIStatusActive,
|
||||
RevokedAt: 0,
|
||||
RevocationReason: "",
|
||||
}
|
||||
|
||||
@@ -3739,6 +3739,52 @@ func (api *API) RepoTree(w http.ResponseWriter, r *http.Request, params map[stri
|
||||
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) {
|
||||
var repo models.Repo
|
||||
var err error
|
||||
@@ -6388,7 +6434,7 @@ func validateTLSPKIServerCert(store *db.Store, certID string, required bool) err
|
||||
if cert.IsCA {
|
||||
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")
|
||||
}
|
||||
parsed, err = parseCertificateFromPEM(cert.CertPEM)
|
||||
@@ -6417,7 +6463,7 @@ func validateTLSPKIClientCA(store *db.Store, caID string, required bool) error {
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
parsed, err = parseCertificateFromPEM(ca.CertPEM)
|
||||
|
||||
@@ -343,7 +343,7 @@ func (api *API) CreatePKIRootCA(w http.ResponseWriter, r *http.Request, _ map[st
|
||||
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)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
|
||||
@@ -415,7 +415,7 @@ func (api *API) CreatePKIIntermediateCA(w http.ResponseWriter, r *http.Request,
|
||||
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)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
|
||||
@@ -543,7 +543,7 @@ func (api *API) IssuePKICert(w http.ResponseWriter, r *http.Request, _ map[strin
|
||||
return
|
||||
}
|
||||
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.CreatedBySubjectID = createdBySubjectID
|
||||
cert.CreatedBySubjectName = createdBySubjectName
|
||||
@@ -661,7 +661,7 @@ func (api *API) ImportPKICert(w http.ResponseWriter, r *http.Request, _ map[stri
|
||||
KeyPEM: keyPEM,
|
||||
NotBefore: importedCert.NotBefore.UTC().Unix(),
|
||||
NotAfter: importedCert.NotAfter.UTC().Unix(),
|
||||
Status: "active",
|
||||
Status: models.PKIStatusActive,
|
||||
RevokedAt: 0,
|
||||
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")
|
||||
return
|
||||
}
|
||||
if cert.Status == "revoked" {
|
||||
if cert.Status == models.PKIStatusRevoked {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "already revoked"})
|
||||
return
|
||||
}
|
||||
@@ -793,7 +793,7 @@ func (api *API) RevokePKICert(w http.ResponseWriter, r *http.Request, params map
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
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) {
|
||||
@@ -1360,13 +1360,10 @@ func buildCRLPEM(ca models.PKICA, certs []models.PKICert) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
for i = 0; i < len(certs); i++ {
|
||||
if certs[i].Status != "revoked" {
|
||||
continue
|
||||
}
|
||||
if certs[i].Status != models.PKIStatusRevoked { continue }
|
||||
|
||||
serial, ok = new(big.Int).SetString(strings.TrimSpace(certs[i].SerialHex), 16)
|
||||
if !ok || serial == nil {
|
||||
continue
|
||||
}
|
||||
if !ok || serial == nil { continue }
|
||||
if certs[i].RevokedAt > 0 {
|
||||
when = time.Unix(certs[i].RevokedAt, 0).UTC()
|
||||
} else {
|
||||
|
||||
@@ -21,3 +21,7 @@ const UserGroupScopeAllUsers string = "all_users"
|
||||
|
||||
const RPMRepoModeLocal string = "local"
|
||||
const RPMRepoModeMirror string = "mirror"
|
||||
|
||||
const PKIStatusActive string = "active"
|
||||
const PKIStatusRevoked string = "revoked"
|
||||
const PKIStatusDeleted string = "deleted"
|
||||
|
||||
@@ -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("GET", "/api/repos/:id/commits", api.RepoCommits)
|
||||
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/raw", api.RepoBlobRaw)
|
||||
router.Handle("GET", "/api/repos/:id/history", api.RepoFileHistory)
|
||||
|
||||
+21
-1
@@ -309,6 +309,12 @@ export interface RepoTreeEntry {
|
||||
type: 'file' | 'dir'
|
||||
}
|
||||
|
||||
export interface RepoTreeEntryCommit extends RepoTreeEntry {
|
||||
last_hash: string
|
||||
last_message: string
|
||||
last_when: string
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string
|
||||
project_id: string
|
||||
@@ -2012,16 +2018,30 @@ export const api = {
|
||||
const qs = params.toString()
|
||||
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) => {
|
||||
const params = new URLSearchParams()
|
||||
if (ref) params.set('ref', ref)
|
||||
params.set('path', path)
|
||||
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()
|
||||
if (ref) params.set('ref', ref)
|
||||
params.set('path', path)
|
||||
if (limit !== undefined) params.set('limit', String(limit))
|
||||
return request<RepoCommit[]>(`/api/repos/${repoId}/history?${params.toString()}`)
|
||||
},
|
||||
getRepoFileDiff: (repoId: string, ref: string | undefined, path: string) => {
|
||||
|
||||
@@ -306,7 +306,7 @@ export default function Layout() {
|
||||
<Typography
|
||||
variant="caption"
|
||||
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}
|
||||
</Typography>
|
||||
@@ -337,7 +337,7 @@ export default function Layout() {
|
||||
{item.icon}
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{ noWrap: true }}
|
||||
primaryTypographyProps={{ noWrap: true, variant: 'body2', sx: { fontWeight: 400 } }}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -177,7 +177,27 @@ export function createAppTheme(mode: PaletteMode, scheme: ThemeScheme) {
|
||||
}
|
||||
},
|
||||
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: {
|
||||
MuiDialog: {
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function BoardCardContent(props: BoardCardContentProps) {
|
||||
{props.dragHandle}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<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}
|
||||
</Typography>
|
||||
{completed ? <CheckCircleIcon color="success" sx={{ fontSize: 15, flexShrink: 0 }} /> : null}
|
||||
|
||||
@@ -111,7 +111,7 @@ function ChartTooltip({ tt }: { tt: TooltipState }) {
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18)', zIndex: 10, whiteSpace: 'nowrap',
|
||||
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) => (
|
||||
<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 }} />
|
||||
@@ -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', 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}
|
||||
</Box>
|
||||
{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,
|
||||
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>
|
||||
</Paper>
|
||||
)
|
||||
@@ -214,7 +214,7 @@ function StatusDonutChart({ data, total }: { data: StatusDatum[]; total: number
|
||||
))}
|
||||
</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>
|
||||
</Group>
|
||||
</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 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={{ fontWeight: 600 }}>{d.count}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 400 }}>{d.count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -428,7 +428,7 @@ function OpenCardAgeChart({ width, data }: { width: number; data: AgeBucket[] })
|
||||
style={{ cursor: 'default' }}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
@@ -514,7 +514,7 @@ function AssigneeWorkloadChart({ width, data }: { width: number; data: AssigneeD
|
||||
return (
|
||||
<g key={d.name}>
|
||||
<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)}
|
||||
</text>
|
||||
<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
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -165,7 +165,7 @@ function SortableRow({
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: v.color, flexShrink: 0 }} />
|
||||
) : null}
|
||||
<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>
|
||||
{showDates(field) && (v.start_date || v.end_date) ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
|
||||
@@ -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={{ 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" />
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -184,7 +184,7 @@ function KanbanColumn({
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ function buildColumns(
|
||||
{typeColor ? (
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, bgcolor: typeColor }} />
|
||||
) : 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}
|
||||
</Typography>
|
||||
{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 color = resolveBoardFieldColor(r.props.card_type, typeValues)
|
||||
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>
|
||||
)
|
||||
@@ -292,7 +292,7 @@ export default function BoardTableView(props: BoardTableViewProps) {
|
||||
<IconButton size="small" tabIndex={-1}>
|
||||
{isCollapsed ? <ExpandMoreIcon fontSize="small" /> : <ExpandLessIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Chip label={group.rows.length} size="small" />
|
||||
|
||||
@@ -478,7 +478,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
<Chip
|
||||
label={typeValues.find((v) => v.value === props_.card_type)?.label ?? props_.card_type}
|
||||
size="small"
|
||||
sx={{ bgcolor: currentTypeColor, color: '#fff', fontWeight: 600, mt: 0.5 }}
|
||||
sx={{ bgcolor: currentTypeColor, color: '#fff', fontWeight: 400, mt: 0.5 }}
|
||||
/>
|
||||
) : null}
|
||||
{isCompleted ? (
|
||||
@@ -787,7 +787,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 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}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -975,7 +975,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
<Divider sx={{ mt: 'auto' }} />
|
||||
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="caption" color="error" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="caption" color="error" sx={{ fontWeight: 400 }}>
|
||||
Danger zone
|
||||
</Typography>
|
||||
{confirmDelete ? (
|
||||
|
||||
@@ -25,6 +25,8 @@ interface CodeBlockProps {
|
||||
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) {
|
||||
const lines = useMemo(() => {
|
||||
const rawLines = code.replace(/\n$/, '').split('\n')
|
||||
@@ -47,6 +49,7 @@ export default function CodeBlock({ code = '', language = 'diff', showLineNumber
|
||||
backgroundColor: 'transparent',
|
||||
lineHeight: 1.4,
|
||||
fontSize: '0.85rem',
|
||||
fontFamily: codeFontFamily,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: showLineNumbers ? 'auto 1fr' : '1fr',
|
||||
columnGap: showLineNumbers ? 12 : 0
|
||||
|
||||
@@ -171,7 +171,7 @@ export function CommitMessage(props: CommitMessageProps) {
|
||||
const collapsible = bodyLines > collapseAfterLines
|
||||
return (
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2">
|
||||
{subject || 'No commit message'}
|
||||
</Typography>
|
||||
{hasBody ? (
|
||||
|
||||
@@ -2,7 +2,19 @@ import ListItemText from '@mui/material/ListItemText'
|
||||
import type { ListItemTextProps } from '@mui/material/ListItemText'
|
||||
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) {
|
||||
const sx: SxProps<Theme> = (props.sx ? [compactSx, props.sx] : compactSx) as SxProps<Theme>
|
||||
|
||||
@@ -27,7 +27,7 @@ function ContextToolbarLink(props: { to: string; children: ReactNode }) {
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
fontWeight: 300,
|
||||
textDecoration: 'none',
|
||||
minWidth: 0,
|
||||
maxWidth: 220,
|
||||
@@ -72,7 +72,7 @@ export default function ContextToolbar(props: ContextToolbarProps) {
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.disabled',
|
||||
fontWeight: 800,
|
||||
fontWeight: 400,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
flexShrink: 0,
|
||||
@@ -91,7 +91,7 @@ export default function ContextToolbar(props: ContextToolbarProps) {
|
||||
{item.label}
|
||||
</ContextToolbarLink>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 300, color: 'text.secondary' }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -105,7 +105,7 @@ export default function ContextToolbar(props: ContextToolbarProps) {
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 750,
|
||||
fontWeight: 400,
|
||||
color: 'text.primary',
|
||||
minWidth: 0,
|
||||
maxWidth: 340,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type LazyCodeBlockProps = {
|
||||
}
|
||||
|
||||
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) {
|
||||
const { code = '' } = props
|
||||
@@ -25,7 +26,7 @@ export default function LazyCodeBlock(props: LazyCodeBlockProps) {
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
//fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace'
|
||||
fontFamily: codeFontFamily
|
||||
}}
|
||||
>
|
||||
<code>{code}</code>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.2,
|
||||
py: 0.7,
|
||||
py: 0.6,
|
||||
borderRadius: 999,
|
||||
color: (theme: Theme) => {
|
||||
if (active) return theme.palette.primary.contrastText
|
||||
@@ -120,7 +120,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.disabled',
|
||||
fontWeight: 800,
|
||||
fontWeight: 400,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
flexShrink: 0,
|
||||
@@ -132,7 +132,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
className="project-name"
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 750,
|
||||
fontWeight: 400,
|
||||
minWidth: 0,
|
||||
maxWidth: 260,
|
||||
overflow: 'hidden',
|
||||
@@ -144,7 +144,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : 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) => {
|
||||
const active: boolean = location.pathname === item.path || location.pathname.startsWith(`${item.path}/`)
|
||||
return (
|
||||
@@ -155,7 +155,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
sx={navItemSx(active)}
|
||||
>
|
||||
{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}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -27,7 +27,7 @@ function buildProjectColumns(
|
||||
renderCell: (p) => (
|
||||
<Typography
|
||||
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()}
|
||||
>
|
||||
{p.name}
|
||||
|
||||
@@ -27,8 +27,8 @@ export default function RepoMarkdown(props: RepoMarkdownProps) {
|
||||
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)} />,
|
||||
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)} />,
|
||||
h6: ({ node, ...rest }: any) => <Typography component="h6" 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: 400 }} {...headingProps(rest)} />,
|
||||
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%' }} />,
|
||||
code: ({ className, children, ...rest }: any) => {
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function RepoSubNav(props: RepoSubNavProps) {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{items.map((item: RepoNavItem) => (
|
||||
<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}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -30,7 +30,7 @@ function typeChip(r: Repo) {
|
||||
<Chip
|
||||
label={r.type || 'git'}
|
||||
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
|
||||
key={col.key}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontWeight: 400,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
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'
|
||||
|
||||
type SplitPanelProps = {
|
||||
left: ReactNode
|
||||
right: ReactNode
|
||||
defaultRatio?: number
|
||||
collapsible?: boolean
|
||||
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 [splitDragging, setSplitDragging] = useState<boolean>(false)
|
||||
const [collapsedSide, setCollapsedSide] = useState<CollapsedSide>(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 container: HTMLDivElement | null = containerRef.current
|
||||
if (!splitDragging || !container) return
|
||||
if (!splitDragging || collapsedSide || !container) return
|
||||
const rect: DOMRect = container.getBoundingClientRect()
|
||||
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)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={[
|
||||
{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`,
|
||||
gridTemplateColumns,
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
@@ -46,7 +69,8 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gridColumn: '1',
|
||||
display: collapsedSide === 'left' ? 'none' : 'flex',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
@@ -60,9 +84,11 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
|
||||
{left}
|
||||
</Box>
|
||||
<Box
|
||||
onPointerDown={() => setSplitDragging(true)}
|
||||
onPointerDown={handleDividerPointerDown}
|
||||
sx={{
|
||||
cursor: 'col-resize',
|
||||
gridColumn: '2',
|
||||
position: 'relative',
|
||||
cursor: collapsedSide ? 'default' : 'col-resize',
|
||||
display: 'grid',
|
||||
placeItems: 'stretch',
|
||||
userSelect: 'none',
|
||||
@@ -76,10 +102,54 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
|
||||
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
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gridColumn: '3',
|
||||
display: collapsedSide === 'right' ? 'none' : 'flex',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -30,6 +30,7 @@ import { formatEpochTime } from '../time'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import PaginationControls from '../components/PaginationControls'
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts || ts <= 0) {
|
||||
@@ -93,6 +94,10 @@ export default function AdminPKIPage() {
|
||||
const [selectedCA, setSelectedCA] = useState('')
|
||||
const [caQuery, setCAQuery] = 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 [certSourceFilter, setCertSourceFilter] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -658,6 +663,57 @@ export default function AdminPKIPage() {
|
||||
].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 cert: PKICert | undefined = certs.find((item) => item.id === id)
|
||||
if (!cert) return id
|
||||
@@ -863,7 +919,7 @@ export default function AdminPKIPage() {
|
||||
}
|
||||
>
|
||||
<List>
|
||||
{filteredCerts.map((cert) => (
|
||||
{paginatedCerts.map((cert: PKICert) => (
|
||||
<ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<CompactListItemText
|
||||
@@ -919,12 +975,33 @@ export default function AdminPKIPage() {
|
||||
</ListItem>
|
||||
) : null}
|
||||
</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
|
||||
collapsible
|
||||
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={
|
||||
<HeaderActionButton variant="outlined" onClick={openNewACME}>
|
||||
New ACME Profile
|
||||
@@ -932,7 +1009,7 @@ export default function AdminPKIPage() {
|
||||
}
|
||||
>
|
||||
<List>
|
||||
{acmeProfiles.map((item) => (
|
||||
{filteredACMEProfiles.map((item: ACMEProfile) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<CompactListItemText
|
||||
@@ -972,6 +1049,11 @@ export default function AdminPKIPage() {
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
{filteredACMEProfiles.length === 0 ? (
|
||||
<ListItem>
|
||||
<CompactListItemText primary="No ACME profiles found." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
@@ -1028,7 +1110,19 @@ export default function AdminPKIPage() {
|
||||
<SectionCard
|
||||
collapsible
|
||||
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={
|
||||
<HeaderActionButton variant="outlined" onClick={openNewACMEOrder} disabled={acmeProfiles.length === 0}>
|
||||
New ACME Order
|
||||
@@ -1036,7 +1130,7 @@ export default function AdminPKIPage() {
|
||||
}
|
||||
>
|
||||
<List>
|
||||
{acmeOrders.map((item) => (
|
||||
{filteredACMEOrders.map((item: ACMEOrder) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<CompactListItemText
|
||||
@@ -1077,6 +1171,11 @@ export default function AdminPKIPage() {
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
{filteredACMEOrders.length === 0 ? (
|
||||
<ListItem>
|
||||
<CompactListItemText primary="No ACME orders found." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@@ -409,7 +409,7 @@ export default function BoardPage() {
|
||||
}}
|
||||
aria-disabled={creatingStandardFieldValues}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontWeight: 400,
|
||||
cursor: creatingStandardFieldValues ? 'default' : 'pointer',
|
||||
pointerEvents: creatingStandardFieldValues ? 'none' : 'auto',
|
||||
}}
|
||||
|
||||
@@ -318,7 +318,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontWeight: selectedImage === image ? 600 : 400 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: selectedImage === image ? 400 : 300 }}>
|
||||
{image || '(root)'}
|
||||
</Typography>
|
||||
}
|
||||
@@ -377,7 +377,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
<ListItemButton onClick={() => handleTagSelect(tag)}>
|
||||
<ListItemText
|
||||
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}
|
||||
</Typography>
|
||||
}
|
||||
|
||||
@@ -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 { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
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 ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
@@ -12,6 +12,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
|
||||
|
||||
export default function RepoGitBranchesPage() {
|
||||
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 (
|
||||
<ProjectPageFrame
|
||||
projectId={projectId ?? ''}
|
||||
@@ -235,52 +321,12 @@ export default function RepoGitBranchesPage() {
|
||||
Loading branches...
|
||||
</Typography>
|
||||
) : null}
|
||||
<List>
|
||||
{branches.map((branch) => (
|
||||
<ListItem key={branch.name} divider secondaryAction={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{branch.name === defaultBranch ? (
|
||||
<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>
|
||||
<ResizableSortableTable
|
||||
columns={branchColumns}
|
||||
data={branches}
|
||||
getRowKey={(branch: BranchInfo) => branch.name}
|
||||
defaultSortKey="name"
|
||||
/>
|
||||
</Paper>
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Branch</DialogTitle>
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { Box, Chip, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Tab, Tabs, Tooltip, Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Chip, Collapse, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Tab, Tabs, Tooltip, Typography } from '@mui/material'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api, Project, Repo, RepoCommitDetail } from '../api'
|
||||
import { CommitIdentity, CommitMessage } from '../components/CommitMeta'
|
||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||
import ContextToolbar from '../components/ContextToolbar'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import SplitPanel from '../components/SplitPanel'
|
||||
import PillNavButton from '../components/PillNavButton'
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
|
||||
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
|
||||
import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined'
|
||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
||||
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() {
|
||||
const { projectId, repoId, hash } = useParams()
|
||||
@@ -26,6 +45,7 @@ export default function RepoGitCommitDetailPage() {
|
||||
const [fileDiff, setFileDiff] = useState<string>('')
|
||||
const [selectedFile, setSelectedFile] = useState<string>('')
|
||||
const [diffView, setDiffView] = useState<'unified' | 'split'>('unified')
|
||||
const [collapsedDiffs, setCollapsedDiffs] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId || !hash) return
|
||||
@@ -94,93 +114,262 @@ export default function RepoGitCommitDetailPage() {
|
||||
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 extracted = extractFileDiffFromCommitDiff(diff, file)
|
||||
setSelectedFile(file)
|
||||
setFileDiff(extracted)
|
||||
}
|
||||
|
||||
const renderSplitDiff = (raw: string) => {
|
||||
const lines = raw.replace(/\r\n/g, '\n').split('\n')
|
||||
let leftLine = 0
|
||||
let rightLine = 0
|
||||
const hunkRe = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
||||
const rows = lines.map((line) => {
|
||||
const prefix = line.charAt(0)
|
||||
const isAdd = prefix === '+'
|
||||
const isDel = prefix === '-'
|
||||
const isHunk = prefix === '@'
|
||||
const isMeta = line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('+++') || line.startsWith('---')
|
||||
const buildDiffRows = (raw: string): DiffRenderRow[] => {
|
||||
const lines: string[] = raw.replace(/\r\n/g, '\n').split('\n')
|
||||
const hunkRe: RegExp = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
||||
const rows: DiffRenderRow[] = []
|
||||
let leftLine: number = 0
|
||||
let rightLine: number = 0
|
||||
let i: number
|
||||
let line: string
|
||||
let prefix: string
|
||||
let isAdd: boolean
|
||||
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) {
|
||||
const match = line.match(hunkRe)
|
||||
match = line.match(hunkRe)
|
||||
if (match) {
|
||||
leftLine = Number(match[1])
|
||||
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
|
||||
const showRightNum = !isDel && !isMeta
|
||||
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,
|
||||
rows.push({
|
||||
text: line,
|
||||
leftNum,
|
||||
rightNum,
|
||||
isAdd,
|
||||
isDel,
|
||||
isHunk,
|
||||
isMeta
|
||||
}
|
||||
})
|
||||
const monoFont = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const renderSide = (side: 'left' | 'right', label: string) => (
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
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: 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
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
fontWeight: 600
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 400 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{rows.map((row, idx) => {
|
||||
const text = side === 'left' ? row.left : row.right
|
||||
const num = 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'
|
||||
{rows.map((row: DiffRenderRow, idx: number) => {
|
||||
const text: string = side === 'left' && row.isAdd ? '' : side === 'right' && row.isDel ? '' : row.text
|
||||
const num: number = side === 'left' ? row.leftNum : row.rightNum
|
||||
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={{
|
||||
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
|
||||
}}
|
||||
>
|
||||
<Box sx={lineNumberSx}>
|
||||
{num || ''}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, whiteSpace: 'pre' }}>{text}</Box>
|
||||
<Box sx={{ minWidth: 0, whiteSpace: 'pre' }}>{text || '\u00a0'}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SplitPanel
|
||||
defaultRatio={0.5}
|
||||
@@ -222,6 +411,74 @@ export default function RepoGitCommitDetailPage() {
|
||||
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 (
|
||||
<ProjectPageFrame
|
||||
projectId={projectId ?? ''}
|
||||
@@ -271,13 +528,8 @@ export default function RepoGitCommitDetailPage() {
|
||||
</List>
|
||||
</Paper>
|
||||
)} 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 }}>
|
||||
<Tooltip title="Back">
|
||||
<IconButton size="small" onClick={() => navigate(-1)} aria-label="Back">
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CommitIdentity
|
||||
author={detail?.author}
|
||||
email={detail?.email}
|
||||
@@ -356,17 +608,13 @@ export default function RepoGitCommitDetailPage() {
|
||||
</Box>
|
||||
) : null}
|
||||
{selectedFile ? (
|
||||
fileDiff ? (
|
||||
diffView === 'unified' ? (
|
||||
<LazyCodeBlock code={fileDiff} language="diff" showLineNumbers />
|
||||
) : (
|
||||
renderSplitDiff(fileDiff)
|
||||
)
|
||||
) : (
|
||||
<Typography>No diff available.</Typography>
|
||||
)
|
||||
renderFileDiffPanel({
|
||||
path: selectedFile,
|
||||
status: detail?.files.find((file) => file.path === selectedFile)?.status || 'modified',
|
||||
diff: stripDiffFileHeader(fileDiff)
|
||||
})
|
||||
) : diff ? (
|
||||
diffView === 'unified' ? <LazyCodeBlock code={diff} language="diff" showLineNumbers /> : renderSplitDiff(diff)
|
||||
buildFileDiffs().map((item: CommitFileDiff) => renderFileDiffPanel(item))
|
||||
) : (
|
||||
<Typography>No diff available.</Typography>
|
||||
)}
|
||||
|
||||
@@ -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 { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api, Project, Repo, RepoCommit } from '../api'
|
||||
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
|
||||
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 ContextToolbar from '../components/ContextToolbar'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
|
||||
import { formatCommitTime } from '../time'
|
||||
|
||||
export default function RepoGitCommitsPage() {
|
||||
const { projectId, repoId } = useParams()
|
||||
@@ -24,6 +29,9 @@ export default function RepoGitCommitsPage() {
|
||||
const [query, setQuery] = useState<string>('')
|
||||
const [page, setPage] = useState<number>(0)
|
||||
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(() => {
|
||||
const urlRef = searchParams.get('ref') || ''
|
||||
@@ -77,12 +85,65 @@ export default function RepoGitCommitsPage() {
|
||||
}, [repoId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled: boolean
|
||||
const offset: number = page * pageSize
|
||||
let historyLimit: number
|
||||
|
||||
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)
|
||||
.then((list) => setCommits(Array.isArray(list) ? list : []))
|
||||
.catch(() => setCommits([]))
|
||||
}, [repoId, ref, page, pageSize, query])
|
||||
.then((list) => {
|
||||
if (cancelled) return
|
||||
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 () => {
|
||||
if (!repoId || !compareBase || !compareHead) return
|
||||
@@ -90,6 +151,152 @@ export default function RepoGitCommitsPage() {
|
||||
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 (
|
||||
<ProjectPageFrame
|
||||
projectId={projectId ?? ''}
|
||||
@@ -110,7 +317,7 @@ export default function RepoGitCommitsPage() {
|
||||
</Typography>
|
||||
) : null}
|
||||
<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 }}>
|
||||
<AccountTreeIcon />
|
||||
<Autocomplete
|
||||
@@ -127,46 +334,55 @@ export default function RepoGitCommitsPage() {
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 260, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Commit</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search author or message"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<Select size="small" value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
|
||||
{[10, 20, 50].map((size) => (
|
||||
<MenuItem key={size} value={size}>
|
||||
{size}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{filePath ? (
|
||||
<>
|
||||
<Typography variant="subtitle2">File</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{filePath}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="subtitle2">Commit</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search author or message"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<List dense>
|
||||
{commits.map((commit) => (
|
||||
<ListItem key={commit.hash} divider>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={`/projects/${projectId}/repos/${repoId}/commits/${commit.hash}?ref=${encodeURIComponent(ref)}`}
|
||||
>
|
||||
<ListItemText
|
||||
primary={splitCommitMessage(commit.message).subject || commit.message}
|
||||
secondary={<CommitIdentity author={commit.author} email={commit.email} when={commit.when} hash={commit.hash} />}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ResizableSortableTable
|
||||
columns={commitColumns}
|
||||
data={commits}
|
||||
getRowKey={(commit: RepoCommit) => commit.hash}
|
||||
defaultSortKey="when"
|
||||
defaultSortDir="desc"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Page {page + 1}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
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>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Button size="small" variant="outlined" disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
||||
</Select>
|
||||
<Button size="small" variant="outlined" disabled={commitsLoading || page === 0} onClick={handlePrevPage}>
|
||||
Prev
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => setPage((p) => p + 1)}>
|
||||
<Button size="small" variant="outlined" disabled={commitsLoading || !hasNextPage} onClick={handleNextPage}>
|
||||
Next
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -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 { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
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 { formatCommitTime } from '../time'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
||||
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 InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import PillNavButton from '../components/PillNavButton'
|
||||
import GoToFile from '../components/GoToFile'
|
||||
import ContextToolbar from '../components/ContextToolbar'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
@@ -40,6 +44,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
const [tree, setTree] = useState<RepoTreeEntry[]>([])
|
||||
const [treeError, setTreeError] = useState<string | null>(null)
|
||||
const [treeReloadTick, setTreeReloadTick] = useState(0)
|
||||
const [dirEntries, setDirEntries] = useState<RepoTreeEntryCommit[]>([])
|
||||
const [dirLoading, setDirLoading] = useState(false)
|
||||
const [fileQuery, setFileQuery] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [pathSegments, setPathSegments] = useState<string[]>([])
|
||||
@@ -47,10 +53,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<string>('')
|
||||
const [isImageFileSelected, setIsImageFileSelected] = useState(false)
|
||||
const [imagePreviewRef, setImagePreviewRef] = useState('')
|
||||
const [history, setHistory] = useState<RepoCommit[]>([])
|
||||
const [diff, setDiff] = useState<string>('')
|
||||
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 [readmeContent, setReadmeContent] = useState<string>('')
|
||||
const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('')
|
||||
@@ -187,12 +192,37 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
setContent('')
|
||||
setPreviewTab('content')
|
||||
setDiff('')
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}
|
||||
})
|
||||
}, [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 = () => {
|
||||
setReadmeContent('')
|
||||
setReadmeKind('')
|
||||
@@ -244,7 +274,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
var parentPath: string
|
||||
var blob: { content: string }
|
||||
var list: RepoCommit[]
|
||||
var nextHistory: RepoCommit[]
|
||||
var latestCommit: RepoCommit | null
|
||||
|
||||
if (!repoId || !filePath) return
|
||||
parentPath = filePath.split('/').slice(0, -1).join('/')
|
||||
@@ -267,12 +297,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
setContent('')
|
||||
}
|
||||
try {
|
||||
list = await api.listRepoFileHistory(repoId, ref || undefined, filePath)
|
||||
nextHistory = Array.isArray(list) ? list : []
|
||||
setHistory(nextHistory)
|
||||
setSelectedCommit(nextHistory.length ? nextHistory[0] : null)
|
||||
list = await api.listRepoFileHistory(repoId, ref || undefined, filePath, 1)
|
||||
latestCommit = Array.isArray(list) && list.length > 0 ? list[0] : null
|
||||
setSelectedCommit(latestCommit)
|
||||
} catch {
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}
|
||||
}
|
||||
@@ -305,7 +333,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
setImagePreviewRef('')
|
||||
setPreviewTab('content')
|
||||
setDiff('')
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}, [repoId, ref, urlRef, urlDirPath, urlFilePath])
|
||||
|
||||
@@ -320,7 +347,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
setIsImageFileSelected(false)
|
||||
setImagePreviewRef('')
|
||||
setPreviewTab('content')
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
return
|
||||
}
|
||||
@@ -339,12 +365,24 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
setImagePreviewRef('')
|
||||
setPreviewTab('content')
|
||||
setDiff('')
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}
|
||||
|
||||
const handleBreadcrumb = (nextPath: string) => {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -361,7 +399,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
setImagePreviewRef('')
|
||||
setPreviewTab('content')
|
||||
setDiff('')
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}
|
||||
|
||||
@@ -445,39 +482,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
? tree.filter((entry) => matchesFileQuery(entry.name, normalizedQuery))
|
||||
: 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
|
||||
// recent commit touching the file by default, or whatever was picked in the
|
||||
// History tab). Refetches whenever the selected commit changes while the Diff
|
||||
// tab is open. Using the branch ref here would diff only the branch tip, which
|
||||
// is empty for any file the tip commit did not touch.
|
||||
// recent commit touching the file by default). Using the branch ref here would
|
||||
// diff only the branch tip, which is empty for any file the tip commit did not
|
||||
// touch.
|
||||
useEffect(() => {
|
||||
if (previewTab !== 'diff') 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)}` : ''}`
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!src) return ''
|
||||
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
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={(event) => setCodeAnchor(event.currentTarget)}>
|
||||
<CodeIcon />
|
||||
</IconButton>
|
||||
<PillNavButton
|
||||
icon={<CodeIcon fontSize="small" />}
|
||||
onClick={(event) => setCodeAnchor(event.currentTarget)}
|
||||
>
|
||||
Clone
|
||||
</PillNavButton>
|
||||
<Popover
|
||||
open={Boolean(codeAnchor)}
|
||||
anchorEl={codeAnchor}
|
||||
@@ -739,9 +760,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
})}
|
||||
{selectedFile ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||
{pathParts.length > 0? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
|
||||
/
|
||||
</Typography>
|
||||
</Typography>): null}
|
||||
<Typography variant="body2" sx={{ px: 0.5 }}>
|
||||
{selectedFile.split('/').pop()}
|
||||
</Typography>
|
||||
@@ -750,6 +772,58 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</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 (
|
||||
<ProjectPageFrame
|
||||
projectId={projectId ?? ''}
|
||||
@@ -815,8 +889,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Box>
|
||||
{path ? (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={handleBack}>
|
||||
<ListItemButton onClick={handleBack} sx={{ py: 0.3 }}>
|
||||
<ListItemText
|
||||
sx={{ my: 0 }}
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FolderIcon fontSize="small" color="info" />
|
||||
@@ -834,8 +909,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
})
|
||||
.map((entry) => (
|
||||
<ListItem key={entry.path} disablePadding>
|
||||
<ListItemButton onClick={() => handleOpen(entry)}>
|
||||
<ListItemButton onClick={() => handleOpen(entry)} sx={{ py: 0.4 }}>
|
||||
<ListItemText
|
||||
sx={{ my: 0 }}
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={entry.type === 'dir' ? 'directory' : 'file'}>
|
||||
@@ -857,21 +933,30 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</List>
|
||||
</TintedPanel>
|
||||
)} right={(
|
||||
<TintedPanel sx={{ height: '100%', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<TintedPanel sx={{ height: '100%', minHeight: 0, overflowY: 'auto', overflowX: 'hidden', ml: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
|
||||
{renderPathBreadcrumb()}
|
||||
{repoId ? <GoToFile repoId={repoId} refName={ref} onSelect={openFilePath} /> : null}
|
||||
</Box>
|
||||
{selectedFile ? (
|
||||
<Box sx={{ mt: 0 }}>
|
||||
<CommitIdentity
|
||||
author={selectedCommit?.author}
|
||||
email={selectedCommit?.email}
|
||||
when={selectedCommit?.when}
|
||||
hash={selectedCommit?.hash}
|
||||
hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined}
|
||||
hashChip
|
||||
copyHash
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<CommitIdentity
|
||||
author={selectedCommit?.author}
|
||||
email={selectedCommit?.email}
|
||||
when={selectedCommit?.when}
|
||||
hash={selectedCommit?.hash}
|
||||
hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined}
|
||||
hashChip
|
||||
copyHash
|
||||
/>
|
||||
<PillNavButton
|
||||
to={fileHistoryPath(selectedFile)}
|
||||
icon={<HistoryIcon fontSize="small" />}
|
||||
>
|
||||
History
|
||||
</PillNavButton>
|
||||
</Box>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<CommitMessage message={selectedCommit?.message || ''} inline />
|
||||
</Box>
|
||||
@@ -879,13 +964,11 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Tabs
|
||||
value={previewTab}
|
||||
onChange={(_, value) => {
|
||||
onChange={(_, value: 'content' | 'diff') => {
|
||||
setPreviewTab(value)
|
||||
if (value === 'history' && history.length === 0) handleHistory()
|
||||
}}
|
||||
>
|
||||
<Tab label="Code" value="content" />
|
||||
<Tab label="History" value="history" />
|
||||
<Tab label="Diff" value="diff" />
|
||||
</Tabs>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
@@ -966,36 +1049,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Paper>
|
||||
)
|
||||
) : 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' ? (
|
||||
diff ? (
|
||||
<>
|
||||
@@ -1031,30 +1084,28 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
)
|
||||
) : null}
|
||||
</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>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Select a file to preview its contents.
|
||||
</Typography>
|
||||
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
||||
<Box sx={{ mt: 0 }}>
|
||||
{renderDirectoryListing()}
|
||||
{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>
|
||||
)
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</TintedPanel>
|
||||
|
||||
Reference in New Issue
Block a user