Compare commits

...

9 Commits

18 changed files with 472 additions and 12 deletions
+3
View File
@@ -68,6 +68,9 @@ func (s *stubWsService) UpsertCredential(context.Context, workspace.Caller, uuid
func (s *stubWsService) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) { func (s *stubWsService) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
panic("stubWsService.GetCredentialMeta unused") panic("stubWsService.GetCredentialMeta unused")
} }
func (s *stubWsService) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
panic("stubWsService.ListBranches unused")
}
// stubProjectAccess satisfies acp.ProjectAccess for the e2e test. // stubProjectAccess satisfies acp.ProjectAccess for the e2e test.
// Only GetProjectByWorkspace is exercised; the rest panic. // Only GetProjectByWorkspace is exercised; the rest panic.
+3
View File
@@ -327,6 +327,9 @@ func (s *fakeWsSvc) UpsertCredential(context.Context, workspace.Caller, uuid.UUI
func (s *fakeWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) { func (s *fakeWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
return nil, fmt.Errorf("unused") return nil, fmt.Errorf("unused")
} }
func (s *fakeWsSvc) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
return nil, fmt.Errorf("unused")
}
// ===== Tests ===== // ===== Tests =====
+4
View File
@@ -436,6 +436,10 @@ func (s *acpStubWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid
return nil, fmt.Errorf("acpStubWsSvc.GetCredentialMeta unused") return nil, fmt.Errorf("acpStubWsSvc.GetCredentialMeta unused")
} }
func (s *acpStubWsSvc) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
return nil, fmt.Errorf("acpStubWsSvc.ListBranches unused")
}
// acpStubProjectAccess satisfies acp.ProjectAccess; only GetProjectByWorkspace is // acpStubProjectAccess satisfies acp.ProjectAccess; only GetProjectByWorkspace is
// exercised by the branch=main path. // exercised by the branch=main path.
type acpStubProjectAccess struct { type acpStubProjectAccess struct {
+64
View File
@@ -0,0 +1,64 @@
package git
import (
"bufio"
"bytes"
"context"
"strings"
)
// FetchRemoteBranch fetches a single remote branch (origin/<branch>).
// Lighter than Fetch --all when we only need one branch.
func (r *DefaultRunner) FetchRemoteBranch(ctx context.Context, dir, branch string, cred *Credential) error {
ctx, cancel := withCtx(ctx, r.cfg.FetchTimeout)
defer cancel()
env, cleanup, err := credentialEnv(r.cfg.TmpDir, cred)
if err != nil {
return err
}
defer cleanup()
_, _, err = r.exec(ctx, dir, env, "fetch", "origin", branch)
return err
}
// Checkout switches the working tree to the given branch.
func (r *DefaultRunner) Checkout(ctx context.Context, dir, branch string) error {
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
_, _, err := r.exec(ctx, dir, nil, "checkout", branch)
return err
}
// ListRemoteBranches queries the remote for all branch names via git ls-remote.
// Returns branch names with refs/heads/ prefix stripped.
func (r *DefaultRunner) ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error) {
ctx, cancel := withCtx(ctx, r.cfg.FetchTimeout)
defer cancel()
env, cleanup, err := credentialEnv(r.cfg.TmpDir, cred)
if err != nil {
return nil, err
}
defer cleanup()
out, _, err := r.exec(ctx, dir, env, "ls-remote", "--heads")
if err != nil {
return nil, err
}
return parseRemoteBranches(out), nil
}
// parseRemoteBranches parses git ls-remote --heads output.
// Each line: "<sha>\trefs/heads/<branch>"
func parseRemoteBranches(buf []byte) []string {
var branches []string
sc := bufio.NewScanner(bytes.NewReader(buf))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if idx := strings.Index(line, "\trefs/heads/"); idx >= 0 {
name := line[idx+len("\trefs/heads/"):]
if name != "" {
branches = append(branches, name)
}
}
}
return branches
}
+23
View File
@@ -0,0 +1,23 @@
package git
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseRemoteBranches(t *testing.T) {
t.Parallel()
input := []byte("aabbccdd\trefs/heads/main\r\n" +
"eeff0011\trefs/heads/develop\n" +
"22334455\trefs/heads/feature/foo\n" +
"deadbeef\trefs/tags/v1.0\n")
got := parseRemoteBranches(input)
require.Equal(t, []string{"main", "develop", "feature/foo"}, got)
}
func TestParseRemoteBranches_Empty(t *testing.T) {
t.Parallel()
got := parseRemoteBranches(nil)
require.Nil(t, got)
}
+3
View File
@@ -23,6 +23,7 @@ const stderrTruncateLimit = 2000
type Runner interface { type Runner interface {
Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error
Fetch(ctx context.Context, dir string, cred *Credential) error Fetch(ctx context.Context, dir string, cred *Credential) error
FetchRemoteBranch(ctx context.Context, dir, branch string, cred *Credential) error
Push(ctx context.Context, dir, branch string, cred *Credential) error Push(ctx context.Context, dir, branch string, cred *Credential) error
Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err error) Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err error)
Status(ctx context.Context, dir string) ([]FileStatus, error) Status(ctx context.Context, dir string) ([]FileStatus, error)
@@ -30,6 +31,8 @@ type Runner interface {
WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error
WorktreeRemove(ctx context.Context, dir, path string) error WorktreeRemove(ctx context.Context, dir, path string) error
WorktreeList(ctx context.Context, dir string) ([]Worktree, error) WorktreeList(ctx context.Context, dir string) ([]Worktree, error)
ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error)
Checkout(ctx context.Context, dir, branch string) error
} }
// FileStatus 表示 `git status --porcelain=v1` 的一行。 // FileStatus 表示 `git status --porcelain=v1` 的一行。
+3
View File
@@ -266,6 +266,9 @@ func (f *fakeWorkspaceSvc) UpsertCredential(_ context.Context, _ workspace.Calle
func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Credential, error) { func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Credential, error) {
panic("not implemented") panic("not implemented")
} }
func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) {
panic("not implemented")
}
// ===== test helpers ===== // ===== test helpers =====
+1
View File
@@ -145,6 +145,7 @@ type WorkspaceService interface {
UpsertCredential(ctx context.Context, c Caller, wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error) UpsertCredential(ctx context.Context, c Caller, wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error)
GetCredentialMeta(ctx context.Context, c Caller, wsID uuid.UUID) (*Credential, error) GetCredentialMeta(ctx context.Context, c Caller, wsID uuid.UUID) (*Credential, error)
ListBranches(ctx context.Context, c Caller, wsID uuid.UUID) ([]string, error)
} }
// WorktreeService 暴露支线 worktree 的应用服务方法。 // WorktreeService 暴露支线 worktree 的应用服务方法。
+4
View File
@@ -115,3 +115,7 @@ func toFileStatusResp(f git.FileStatus) fileStatusResp {
type commitResp struct { type commitResp struct {
SHA string `json:"sha"` SHA string `json:"sha"`
} }
type branchListResp struct {
Branches []string `json:"branches"`
}
+20
View File
@@ -62,6 +62,7 @@ func (h *Handler) Mount(r chi.Router) {
r.Get("/worktrees", h.listWorktrees) r.Get("/worktrees", h.listWorktrees)
r.Post("/worktrees", h.createWorktree) r.Post("/worktrees", h.createWorktree)
r.Get("/branches", h.listBranches)
}) })
r.With(auth).Route("/api/v1/worktrees/{wtID}", func(r chi.Router) { r.With(auth).Route("/api/v1/worktrees/{wtID}", func(r chi.Router) {
r.Delete("/", h.deleteWorktree) r.Delete("/", h.deleteWorktree)
@@ -340,6 +341,25 @@ func (h *Handler) createWorktree(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, toWorktreeResp(wt)) writeJSON(w, http.StatusCreated, toWorktreeResp(wt))
} }
func (h *Handler) listBranches(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
branches, err := h.ws.ListBranches(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, branchListResp{Branches: branches})
}
func (h *Handler) deleteWorktree(w http.ResponseWriter, r *http.Request) { func (h *Handler) deleteWorktree(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r) c, err := h.caller(r)
if err != nil { if err != nil {
+27
View File
@@ -48,6 +48,10 @@ func (f *fakeWS) GetCredentialMeta(_ context.Context, _ Caller, _ uuid.UUID) (*C
return &Credential{Kind: CredKindNone}, nil return &Credential{Kind: CredKindNone}, nil
} }
func (f *fakeWS) ListBranches(_ context.Context, _ Caller, _ uuid.UUID) ([]string, error) {
return []string{"main", "develop"}, nil
}
// fakeResolver 把固定 token 解码为 uuid(沿 internal/project handler_test.go 模式)。 // fakeResolver 把固定 token 解码为 uuid(沿 internal/project handler_test.go 模式)。
type fakeResolver struct{ valid map[string]uuid.UUID } type fakeResolver struct{ valid map[string]uuid.UUID }
@@ -82,3 +86,26 @@ func TestHandler_CreateWorkspace_Smoke(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
} }
func TestHandler_ListBranches_Smoke(t *testing.T) {
t.Parallel()
ws := &fakeWS{}
uid := uuid.New()
tok := "tok-branches"
resolver := &fakeResolver{valid: map[string]uuid.UUID{tok: uid}}
h := NewHandler(ws, nil, nil, resolver, fakeAdmin{})
r := chi.NewRouter()
r.Use(middleware.RequestID)
h.Mount(r)
wsID := uuid.New()
req := httptest.NewRequest("GET", "/api/v1/workspaces/"+wsID.String()+"/branches", nil)
req.Header.Set("Authorization", "Bearer "+tok)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp branchListResp
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
require.Equal(t, []string{"main", "develop"}, resp.Branches)
}
+41 -1
View File
@@ -167,7 +167,24 @@ func (s *workspaceService) Update(ctx context.Context, c Caller, wsID uuid.UUID,
if in.Description != nil { if in.Description != nil {
desc = *in.Description desc = *in.Description
} }
if in.DefaultBranch != nil { if in.DefaultBranch != nil && *in.DefaultBranch != ws.DefaultBranch {
// 切换 default_branch:先校验→拉远端→切到新分支,再写 DB。
// 任一 git 步骤失败则不改库,让调用方感知并自行重试/回滚。
if err := ValidateBranch(*in.DefaultBranch); err != nil {
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch")
}
cred, credErr := s.loadDecryptedCredential(ctx, wsID)
if credErr != nil {
return nil, credErr
}
if fetchErr := s.gitr.FetchRemoteBranch(ctx, ws.MainPath, *in.DefaultBranch, cred); fetchErr != nil {
return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "fetch remote branch")
}
if coErr := s.gitr.Checkout(ctx, ws.MainPath, *in.DefaultBranch); coErr != nil {
return nil, errs.Wrap(coErr, errs.CodeGitCmdFailed, "checkout branch")
}
defaultBr = *in.DefaultBranch
} else if in.DefaultBranch != nil {
if err := ValidateBranch(*in.DefaultBranch); err != nil { if err := ValidateBranch(*in.DefaultBranch); err != nil {
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch") return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch")
} }
@@ -311,6 +328,29 @@ func (s *workspaceService) GetCredentialMeta(ctx context.Context, c Caller, wsID
return cred, nil return cred, nil
} }
func (s *workspaceService) ListBranches(ctx context.Context, c Caller, wsID uuid.UUID) ([]string, error) {
ws, err := s.repo.GetWorkspaceByID(ctx, wsID)
if err != nil {
return nil, err
}
if canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
return nil, err
} else if !canRead {
return nil, errs.New(errs.CodeForbidden, "no read access")
}
if ws.MainPath == "" {
return nil, errs.New(errs.CodeInvalidInput, "workspace not yet cloned")
}
if _, statErr := os.Stat(ws.MainPath); errors.Is(statErr, os.ErrNotExist) {
return nil, errs.New(errs.CodeInvalidInput, "workspace not yet cloned")
}
cred, err := s.loadDecryptedCredential(ctx, wsID)
if err != nil {
return nil, err
}
return s.gitr.ListRemoteBranches(ctx, ws.MainPath, cred)
}
func (s *workspaceService) encryptForUpsert(wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error) { func (s *workspaceService) encryptForUpsert(wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error) {
cred := &Credential{ cred := &Credential{
ID: uuid.New(), ID: uuid.New(),
@@ -226,6 +226,45 @@ func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) { func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
return nil, nil return nil, nil
} }
func (f *fakeGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error { return nil }
func (f *fakeGit) Checkout(_ context.Context, _, _ string) error { return nil }
func (f *fakeGit) ListRemoteBranches(_ context.Context, _ string, _ *git.Credential) ([]string, error) {
return nil, nil
}
// trackGit 包装 git.Runner,记录 FetchRemoteBranch / Checkout 调用以供断言。
// 嵌入 Runner 接口本身可避免为每个方法重复转发。
type trackGit struct {
git.Runner
fetchRemoteBranchCalls []string
checkoutCalls []string
}
func (t *trackGit) FetchRemoteBranch(_ context.Context, _, branch string, _ *git.Credential) error {
t.fetchRemoteBranchCalls = append(t.fetchRemoteBranchCalls, branch)
return nil
}
func (t *trackGit) Checkout(_ context.Context, _, branch string) error {
t.checkoutCalls = append(t.checkoutCalls, branch)
return nil
}
type errFetchGit struct {
git.Runner
err error
}
func (e *errFetchGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error {
return e.err
}
type errCheckoutGit struct {
git.Runner
err error
}
func (e *errCheckoutGit) Checkout(_ context.Context, _, _ string) error { return e.err }
type noopAudit struct{} type noopAudit struct{}
@@ -379,3 +418,128 @@ func TestCloneRunner_Failure(t *testing.T) {
require.Equal(t, SyncStatusError, got.SyncStatus) require.Equal(t, SyncStatusError, got.SyncStatus)
require.Contains(t, got.LastSyncError, "boom") require.Contains(t, got.LastSyncError, "boom")
} }
func TestWorkspaceService_Update_DefaultBranch_Switch(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
fg := &trackGit{Runner: &fakeGit{}}
svc, repo := newSvc(t, pa, nil, fg)
caller := Caller{UserID: uuid.New()}
// Seed a workspace with main_path set.
wsID := uuid.New()
existing := &Workspace{
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
DefaultBranch: "main", MainPath: "/data/ws1/main",
}
repo.wss[wsID] = existing
newBr := "develop"
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
DefaultBranch: &newBr,
})
require.NoError(t, err)
require.Equal(t, "develop", repo.wss[wsID].DefaultBranch)
require.Equal(t, []string{"develop"}, fg.fetchRemoteBranchCalls)
require.Equal(t, []string{"develop"}, fg.checkoutCalls)
}
func TestWorkspaceService_Update_DefaultBranch_FetchFails(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
fg := &errFetchGit{Runner: &fakeGit{}, err: errors.New("remote branch not found")}
svc, repo := newSvc(t, pa, nil, fg)
caller := Caller{UserID: uuid.New()}
wsID := uuid.New()
repo.wss[wsID] = &Workspace{
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
DefaultBranch: "main", MainPath: "/data/ws1/main",
}
newBr := "nonexistent"
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
DefaultBranch: &newBr,
})
require.Error(t, err)
// DB should NOT be updated
require.Equal(t, "main", repo.wss[wsID].DefaultBranch)
}
func TestWorkspaceService_Update_DefaultBranch_CheckoutFails(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
fg := &errCheckoutGit{Runner: &fakeGit{}, err: errors.New("dirty working tree")}
svc, repo := newSvc(t, pa, nil, fg)
caller := Caller{UserID: uuid.New()}
wsID := uuid.New()
repo.wss[wsID] = &Workspace{
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
DefaultBranch: "main", MainPath: "/data/ws1/main",
}
newBr := "develop"
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
DefaultBranch: &newBr,
})
require.Error(t, err)
// DB should NOT be updated
require.Equal(t, "main", repo.wss[wsID].DefaultBranch)
}
func TestWorkspaceService_Update_DefaultBranch_SameValue_NoGitOps(t *testing.T) {
// 把 default_branch 更新成相同值时,不应触发任何 git 操作(避免无谓的 fetch/checkout)。
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
fg := &trackGit{Runner: &fakeGit{}}
svc, repo := newSvc(t, pa, nil, fg)
caller := Caller{UserID: uuid.New()}
wsID := uuid.New()
repo.wss[wsID] = &Workspace{
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
DefaultBranch: "main", MainPath: "/data/ws1/main",
}
sameBr := "main"
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
DefaultBranch: &sameBr,
})
require.NoError(t, err)
require.Empty(t, fg.fetchRemoteBranchCalls)
require.Empty(t, fg.checkoutCalls)
}
func TestWorkspaceService_ListBranches(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
svc, repo := newSvc(t, pa, nil, &fakeGit{})
caller := Caller{UserID: uuid.New()}
wsID := uuid.New()
repo.wss[wsID] = &Workspace{
ID: wsID, ProjectID: pa.pid, Slug: "ws1",
DefaultBranch: "main", MainPath: t.TempDir(),
}
branches, err := svc.ListBranches(context.Background(), caller, wsID)
require.NoError(t, err)
// fakeGit returns nil, nil → empty slice
require.Nil(t, branches)
}
func TestWorkspaceService_ListBranches_NoReadAccess(t *testing.T) {
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: false, canWrite: false}
svc, repo := newSvc(t, pa, nil, &fakeGit{})
caller := Caller{UserID: uuid.New()}
wsID := uuid.New()
repo.wss[wsID] = &Workspace{
ID: wsID, ProjectID: pa.pid, Slug: "ws1",
}
_, err := svc.ListBranches(context.Background(), caller, wsID)
require.Error(t, err)
}
+1
View File
@@ -19,6 +19,7 @@
"license": "ISC", "license": "ISC",
"packageManager": "pnpm@10.32.1", "packageManager": "pnpm@10.32.1",
"dependencies": { "dependencies": {
"@vicons/ionicons5": "^0.13.0",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
+8
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@vicons/ionicons5':
specifier: ^0.13.0
version: 0.13.0
markdown-it: markdown-it:
specifier: ^14.1.1 specifier: ^14.1.1
version: 14.1.1 version: 14.1.1
@@ -524,6 +527,9 @@ packages:
resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vicons/ionicons5@0.13.0':
resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==}
'@vitejs/plugin-vue@6.0.6': '@vitejs/plugin-vue@6.0.6':
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -2065,6 +2071,8 @@ snapshots:
'@typescript-eslint/types': 8.59.1 '@typescript-eslint/types': 8.59.1
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@vicons/ionicons5@0.13.0': {}
'@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))': '@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13 '@rolldown/pluginutils': 1.0.0-rc.13
+4
View File
@@ -52,6 +52,10 @@ export const workspacesApi = {
release: (wtID: string) => release: (wtID: string) =>
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }), request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }),
listBranches: (wsID: string): Promise<string[]> =>
request<{ branches: string[] }>(`/api/v1/workspaces/${enc(wsID)}/branches`)
.then(r => r.branches),
// ===== Git ops ===== // ===== Git ops =====
statusOnMain: (wsID: string) => statusOnMain: (wsID: string) =>
request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`), request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`),
+61 -7
View File
@@ -14,17 +14,39 @@
v-for="c in chat.conversations" v-for="c in chat.conversations"
:key="c.id" :key="c.id"
:class="[ :class="[
'p-2 rounded cursor-pointer hover:bg-neutral-100', 'group p-2 rounded cursor-pointer hover:bg-neutral-100 flex items-center justify-between gap-2',
chat.currentConversation?.id === c.id ? 'bg-blue-50' : '', chat.currentConversation?.id === c.id ? 'bg-blue-50' : '',
]" ]"
@click="open(c.id)" @click="open(c.id)"
> >
<div class="truncate text-sm"> <div class="flex-1 min-w-0">
{{ c.title || '(未命名)' }} <div class="truncate text-sm">
</div> {{ c.title || '(未命名)' }}
<div class="text-xs text-neutral-400"> </div>
{{ formatTime(c.updated_at) }} <div class="text-xs text-neutral-400">
{{ formatTime(c.updated_at) }}
</div>
</div> </div>
<NPopconfirm
:show-icon="false"
@positive-click="chat.deleteConversation(c.id)"
>
<template #trigger>
<NButton
text
size="tiny"
class="opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop
>
<template #icon>
<NIcon><TrashOutline /></NIcon>
</template>
</NButton>
</template>
<template #default>
删除后无法恢复确定删除此会话
</template>
</NPopconfirm>
</div> </div>
<div <div
v-if="chat.conversations.length === 0" v-if="chat.conversations.length === 0"
@@ -43,6 +65,28 @@
选择或新建会话 选择或新建会话
</div> </div>
<template v-else> <template v-else>
<!-- 头部操作栏 -->
<div class="border-b px-4 py-2 flex items-center justify-between">
<div class="text-sm font-medium truncate flex-1">
{{ chat.currentConversation.title || '(未命名)' }}
</div>
<NPopconfirm
:show-icon="false"
@positive-click="deleteCurrentConversation"
>
<template #trigger>
<NButton text size="small">
<template #icon>
<NIcon><TrashOutline /></NIcon>
</template>
删除
</NButton>
</template>
<template #default>
删除后无法恢复确定删除此会话
</template>
</NPopconfirm>
</div>
<div <div
ref="scrollEl" ref="scrollEl"
class="flex-1 overflow-y-auto p-4 space-y-3" class="flex-1 overflow-y-auto p-4 space-y-3"
@@ -101,7 +145,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput } from 'naive-ui' import { NButton, NInput, NPopconfirm, NIcon } from 'naive-ui'
import { TrashOutline } from '@vicons/ionicons5'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
@@ -206,6 +251,15 @@ async function onCreated(id: string) {
await open(id) await open(id)
} }
async function deleteCurrentConversation() {
const id = chat.currentConversation?.id
if (!id) return
await chat.deleteConversation(id)
await router.replace({
path: projectId.value ? `/projects/${projectId.value}/chat` : `/chat`,
})
}
function formatTime(iso: string) { function formatTime(iso: string) {
return new Date(iso).toLocaleString('zh-CN', { hour12: false }) return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} }
@@ -6,7 +6,7 @@
> >
<NButton <NButton
type="primary" type="primary"
@click="showCreate = true" @click="openCreate"
> >
新建 worktree 新建 worktree
</NButton> </NButton>
@@ -31,7 +31,15 @@
<NInput v-model:value="newBranch" /> <NInput v-model:value="newBranch" />
</NFormItem> </NFormItem>
<NFormItem label="基线分支(默认 default_branch)"> <NFormItem label="基线分支(默认 default_branch)">
<NInput v-model:value="newBase" /> <NSelect
v-model:value="newBase"
:options="baseBranchOptions"
:loading="branchesLoading"
clearable
filterable
tag
placeholder="默认使用 default_branch"
/>
</NFormItem> </NFormItem>
</NForm> </NForm>
<template #footer> <template #footer>
@@ -71,11 +79,13 @@ import {
NForm, NForm,
NFormItem, NFormItem,
NInput, NInput,
NSelect,
useMessage, useMessage,
useDialog, useDialog,
} from 'naive-ui' } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
import { useWorkspacesStore } from '@/stores/workspaces' import { useWorkspacesStore } from '@/stores/workspaces'
import { workspacesApi } from '@/api/workspaces'
import CommitDialog from './CommitDialog.vue' import CommitDialog from './CommitDialog.vue'
import type { Workspace, Worktree, WorktreeStatus } from '@/api/types' import type { Workspace, Worktree, WorktreeStatus } from '@/api/types'
@@ -87,16 +97,40 @@ const dialog = useDialog()
const showCreate = ref<boolean>(false) const showCreate = ref<boolean>(false)
const creating = ref<boolean>(false) const creating = ref<boolean>(false)
const newBranch = ref<string>('') const newBranch = ref<string>('')
const newBase = ref<string>('') const newBase = ref<string | null>(null)
const commitFor = ref<Worktree | null>(null) const commitFor = ref<Worktree | null>(null)
// Branch list for base_branch selector
const branches = ref<string[]>([])
const branchesLoading = ref(false)
const STATUS_TAG_TYPE: Record<WorktreeStatus, 'warning' | 'error' | 'default'> = { const STATUS_TAG_TYPE: Record<WorktreeStatus, 'warning' | 'error' | 'default'> = {
active: 'warning', active: 'warning',
pruning: 'error', pruning: 'error',
idle: 'default', idle: 'default',
} }
async function openCreate() {
showCreate.value = true
newBranch.value = ''
newBase.value = null
// Fetch branch list when dialog opens
branchesLoading.value = true
try {
branches.value = await workspacesApi.listBranches(props.ws.id)
} catch {
// Silently fail — user can still type manually
branches.value = []
} finally {
branchesLoading.value = false
}
}
const baseBranchOptions = computed(() =>
branches.value.map((b) => ({ label: b, value: b })),
)
async function onCreate() { async function onCreate() {
if (!newBranch.value.trim()) { if (!newBranch.value.trim()) {
msg.error('分支名为必填') msg.error('分支名为必填')
@@ -110,7 +144,7 @@ async function onCreate() {
}) })
showCreate.value = false showCreate.value = false
newBranch.value = '' newBranch.value = ''
newBase.value = '' newBase.value = null
} catch (e: unknown) { } catch (e: unknown) {
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e))) msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
} finally { } finally {