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) {
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.
// 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) {
return nil, fmt.Errorf("unused")
}
func (s *fakeWsSvc) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
return nil, fmt.Errorf("unused")
}
// ===== Tests =====
+4
View File
@@ -436,6 +436,10 @@ func (s *acpStubWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid
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
// exercised by the branch=main path.
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 {
Clone(ctx context.Context, dst, remoteURL, branch 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
Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err 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
WorktreeRemove(ctx context.Context, dir, path string) 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` 的一行。
+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) {
panic("not implemented")
}
func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) {
panic("not implemented")
}
// ===== 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)
GetCredentialMeta(ctx context.Context, c Caller, wsID uuid.UUID) (*Credential, error)
ListBranches(ctx context.Context, c Caller, wsID uuid.UUID) ([]string, error)
}
// WorktreeService 暴露支线 worktree 的应用服务方法。
+4
View File
@@ -115,3 +115,7 @@ func toFileStatusResp(f git.FileStatus) fileStatusResp {
type commitResp struct {
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.Post("/worktrees", h.createWorktree)
r.Get("/branches", h.listBranches)
})
r.With(auth).Route("/api/v1/worktrees/{wtID}", func(r chi.Router) {
r.Delete("/", h.deleteWorktree)
@@ -340,6 +341,25 @@ func (h *Handler) createWorktree(w http.ResponseWriter, r *http.Request) {
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) {
c, err := h.caller(r)
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
}
func (f *fakeWS) ListBranches(_ context.Context, _ Caller, _ uuid.UUID) ([]string, error) {
return []string{"main", "develop"}, nil
}
// fakeResolver 把固定 token 解码为 uuid(沿 internal/project handler_test.go 模式)。
type fakeResolver struct{ valid map[string]uuid.UUID }
@@ -82,3 +86,26 @@ func TestHandler_CreateWorkspace_Smoke(t *testing.T) {
r.ServeHTTP(w, req)
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 {
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 {
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
}
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) {
cred := &Credential{
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) {
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{}
@@ -379,3 +418,128 @@ func TestCloneRunner_Failure(t *testing.T) {
require.Equal(t, SyncStatusError, got.SyncStatus)
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",
"packageManager": "pnpm@10.32.1",
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"markdown-it": "^14.1.1",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
+8
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@vicons/ionicons5':
specifier: ^0.13.0
version: 0.13.0
markdown-it:
specifier: ^14.1.1
version: 14.1.1
@@ -524,6 +527,9 @@ packages:
resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==}
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':
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2065,6 +2071,8 @@ snapshots:
'@typescript-eslint/types': 8.59.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))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
+4
View File
@@ -52,6 +52,10 @@ export const workspacesApi = {
release: (wtID: string) =>
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 =====
statusOnMain: (wsID: string) =>
request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`),
+61 -7
View File
@@ -14,17 +14,39 @@
v-for="c in chat.conversations"
:key="c.id"
: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' : '',
]"
@click="open(c.id)"
>
<div class="truncate text-sm">
{{ c.title || '(未命名)' }}
</div>
<div class="text-xs text-neutral-400">
{{ formatTime(c.updated_at) }}
<div class="flex-1 min-w-0">
<div class="truncate text-sm">
{{ c.title || '(未命名)' }}
</div>
<div class="text-xs text-neutral-400">
{{ formatTime(c.updated_at) }}
</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
v-if="chat.conversations.length === 0"
@@ -43,6 +65,28 @@
选择或新建会话
</div>
<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
ref="scrollEl"
class="flex-1 overflow-y-auto p-4 space-y-3"
@@ -101,7 +145,8 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
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 { useChatStore } from '@/stores/chat'
@@ -206,6 +251,15 @@ async function onCreated(id: string) {
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) {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
@@ -6,7 +6,7 @@
>
<NButton
type="primary"
@click="showCreate = true"
@click="openCreate"
>
新建 worktree
</NButton>
@@ -31,7 +31,15 @@
<NInput v-model:value="newBranch" />
</NFormItem>
<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>
</NForm>
<template #footer>
@@ -71,11 +79,13 @@ import {
NForm,
NFormItem,
NInput,
NSelect,
useMessage,
useDialog,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { useWorkspacesStore } from '@/stores/workspaces'
import { workspacesApi } from '@/api/workspaces'
import CommitDialog from './CommitDialog.vue'
import type { Workspace, Worktree, WorktreeStatus } from '@/api/types'
@@ -87,16 +97,40 @@ const dialog = useDialog()
const showCreate = ref<boolean>(false)
const creating = ref<boolean>(false)
const newBranch = ref<string>('')
const newBase = ref<string>('')
const newBase = ref<string | 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'> = {
active: 'warning',
pruning: 'error',
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() {
if (!newBranch.value.trim()) {
msg.error('分支名为必填')
@@ -110,7 +144,7 @@ async function onCreate() {
})
showCreate.value = false
newBranch.value = ''
newBase.value = ''
newBase.value = null
} catch (e: unknown) {
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
} finally {