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