diff --git a/docs/reference/tui-gateway-contract-matrix.md b/docs/reference/tui-gateway-contract-matrix.md index 6e62abac8..4df27ac80 100644 --- a/docs/reference/tui-gateway-contract-matrix.md +++ b/docs/reference/tui-gateway-contract-matrix.md @@ -64,3 +64,9 @@ Primary gateway codes used for UI mapping: - No multi-version payload decoding. - No alias method fallback. - No legacy field fallback in event payload. + +## Workspace Boundary For File Preview + +- For `gateway.listFiles`, `gateway.readFile`, `gateway.listGitDiffFiles`, and `gateway.readGitDiffFile`, server-side root resolution is always constrained by the current workspace boundary. +- Request-level `workdir` is kept for protocol compatibility, but runtime implementation does not trust it as an override root. +- When a stored session workdir is outside the current workspace root, the request is rejected with a controlled boundary error. diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index d4cee138c..64b3c5762 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1972,36 +1972,84 @@ func isRuntimeNotFoundError(err error) bool { return errors.Is(err, agentsession.ErrSessionNotFound) || errors.Is(err, os.ErrNotExist) } -// resolveListFilesRoot 按请求、会话、全局配置的优先级确定文件树根目录。 +// resolveListFilesRoot 解析文件预览根目录并强制收敛在当前工作区边界内。 +// 兼容保留请求中的 workdir 字段,但实现层不会信任该字段,避免客户端绕过边界。 func (b *gatewayRuntimePortBridge) resolveListFilesRoot( ctx context.Context, input gateway.ListFilesInput, ) (string, error) { - root := strings.TrimSpace(input.Workdir) - if root == "" && strings.TrimSpace(input.SessionID) != "" && b.sessionStore != nil { + workspaceRoot, err := b.resolveWorkspaceRootForFileAccess() + if err != nil { + return "", err + } + + root := workspaceRoot + if strings.TrimSpace(input.SessionID) != "" && b.sessionStore != nil { session, err := b.loadStoredSession(ctx, strings.TrimSpace(input.SessionID)) if err != nil && !isRuntimeNotFoundError(err) { return "", err } - root = strings.TrimSpace(session.Workdir) + sessionRoot := strings.TrimSpace(session.Workdir) + if sessionRoot != "" { + if !isPathWithinRoot(sessionRoot, workspaceRoot) { + return "", fmt.Errorf("gateway runtime bridge: session workdir escapes current workspace root") + } + root = sessionRoot + } } - if root == "" { - root = strings.TrimSpace(b.currentConfig().Workdir) + absolute, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return "", err } + return filepath.Clean(absolute), nil +} + +// resolveWorkspaceRootForFileAccess 返回当前工作区文件访问根目录。 +// 优先使用 runtime 配置中的 workdir,缺失时回退到当前进程工作目录。 +func (b *gatewayRuntimePortBridge) resolveWorkspaceRootForFileAccess() (string, error) { + root := strings.TrimSpace(b.currentConfig().Workdir) if root == "" { - var err error - root, err = os.Getwd() + cwd, err := os.Getwd() if err != nil { return "", err } + root = cwd } - absolute, err := filepath.Abs(root) + absolute, err := filepath.Abs(filepath.Clean(root)) if err != nil { return "", err } return filepath.Clean(absolute), nil } +// isPathWithinRoot 判断 candidate 是否位于 root 目录内(含自身),同时处理符号链接场景。 +func isPathWithinRoot(candidate string, root string) bool { + candidateAbs, err := filepath.Abs(filepath.Clean(candidate)) + if err != nil { + return false + } + rootAbs, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return false + } + candidateForCheck := candidateAbs + if resolvedCandidate, resolveErr := filepath.EvalSymlinks(candidateAbs); resolveErr == nil { + candidateForCheck = resolvedCandidate + } + rootForCheck := rootAbs + if resolvedRoot, resolveErr := filepath.EvalSymlinks(rootAbs); resolveErr == nil { + rootForCheck = resolvedRoot + } + relative, err := filepath.Rel(rootForCheck, candidateForCheck) + if err != nil { + return false + } + if relative == "." { + return true + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} + // loadStoredSession 通过可选的会话加载接口读取持久会话。 func (b *gatewayRuntimePortBridge) loadStoredSession(ctx context.Context, sessionID string) (agentsession.Session, error) { if b == nil || b.sessionStore == nil { diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index a7395649d..117750fc1 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -17,6 +17,7 @@ import ( configstate "neo-code/internal/config/state" "neo-code/internal/gateway" providertypes "neo-code/internal/provider/types" + "neo-code/internal/repository" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" "neo-code/internal/skills" @@ -1791,48 +1792,47 @@ func TestResolveListFilesRootPriorities(t *testing.T) { t.Fatalf("mkdir: %v", err) } - // priority 1: input.Workdir - bridge1, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) - defer bridge1.Close() - root, err := bridge1.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{Workdir: subDir}) - if err != nil { - t.Fatalf("resolve with workdir: %v", err) - } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) - } - - // priority 2: session workdir (store implements bridgeSessionLoader) + // priority 1: session workdir (must stay inside current workspace root) loaderStore := &bridgeSessionStoreWithLoader{ bridgeSessionStoreStub: bridgeSessionStoreStub{}, session: agentsession.Session{Workdir: subDir}, } - bridge2, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore) - defer bridge2.Close() - root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + cfgMgr1 := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge1, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr1, nil) + defer bridge1.Close() + root, err := bridge1.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) if err != nil { t.Fatalf("resolve with session: %v", err) } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) } - // priority 3: config workdir - cfgMgr := &configManagerStub{cfg: config.Config{Workdir: subDir}} - bridge3, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) - defer bridge3.Close() - root, err = bridge3.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) + // priority 2: config workdir + cfgMgr2 := &configManagerStub{cfg: config.Config{Workdir: subDir}} + bridge2, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr2, nil) + defer bridge2.Close() + root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) if err != nil { t.Fatalf("resolve with config: %v", err) } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) + } + + // input.Workdir should be ignored and not override workspace root + root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{Workdir: t.TempDir()}) + if err != nil { + t.Fatalf("resolve with ignored workdir: %v", err) + } + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) } - // priority 4: os.Getwd - bridge4, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) - defer bridge4.Close() - root, err = bridge4.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) + // priority 3: os.Getwd fallback + bridge3, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + defer bridge3.Close() + root, err = bridge3.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) if err != nil { t.Fatalf("resolve with getwd: %v", err) } @@ -1858,27 +1858,166 @@ func TestResolveListFilesRootSessionNotFound(t *testing.T) { bridgeSessionStoreStub: bridgeSessionStoreStub{}, loadErr: agentsession.ErrSessionNotFound, } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore) + cfgRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) defer bridge.Close() root, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) if err != nil { t.Fatalf("resolve with not-found session should not error: %v", err) } - wd, _ := os.Getwd() - absWd, _ := filepath.Abs(wd) + if root != filepath.Clean(cfgRoot) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(cfgRoot)) + } +} + +func TestResolveListFilesRootFallsBackWhenSessionWorkdirEmpty(t *testing.T) { + cfgRoot := t.TempDir() + loaderStore := &bridgeSessionStoreWithLoader{ + bridgeSessionStoreStub: bridgeSessionStoreStub{}, + session: agentsession.Session{Workdir: " \t "}, + } + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) + defer bridge.Close() + + root, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + if err != nil { + t.Fatalf("resolve with empty session workdir should not error: %v", err) + } + if root != filepath.Clean(cfgRoot) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(cfgRoot)) + } +} + +func TestResolveListFilesRootPropagatesUnexpectedSessionLoadError(t *testing.T) { + cfgRoot := t.TempDir() + loaderStore := &bridgeSessionStoreWithLoader{ + bridgeSessionStoreStub: bridgeSessionStoreStub{}, + loadErr: errors.New("load failed"), + } + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) + defer bridge.Close() + + _, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + if err == nil || err.Error() != "load failed" { + t.Fatalf("expected load failed error, got %v", err) + } +} + +func TestResolveListFilesRootRejectsSessionWorkdirEscapingWorkspaceRoot(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + loaderStore := &bridgeSessionStoreWithLoader{ + bridgeSessionStoreStub: bridgeSessionStoreStub{}, + session: agentsession.Session{Workdir: outsideRoot}, + } + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) + defer bridge.Close() + + _, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + if err == nil || !strings.Contains(err.Error(), "escapes current workspace root") { + t.Fatalf("expected workspace boundary error, got %v", err) + } +} + +func TestIsPathWithinRoot(t *testing.T) { + root := t.TempDir() + insideDir := filepath.Join(root, "inside") + if err := os.MkdirAll(insideDir, 0o755); err != nil { + t.Fatalf("mkdir inside dir: %v", err) + } + + if !isPathWithinRoot(root, root) { + t.Fatal("expected workspace root to be accepted as its own boundary") + } + if !isPathWithinRoot(insideDir, root) { + t.Fatal("expected child dir to be accepted") + } + + outsideRoot := t.TempDir() + if isPathWithinRoot(outsideRoot, root) { + t.Fatal("expected unrelated path to be rejected") + } + + linkPath := filepath.Join(root, "linked-outside") + if err := os.Symlink(outsideRoot, linkPath); err != nil { + t.Fatalf("symlink outside: %v", err) + } + if isPathWithinRoot(linkPath, root) { + t.Fatal("expected symlink escaping workspace root to be rejected") + } +} + +func TestResolveWorkspaceRootForFileAccess(t *testing.T) { + configuredRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: configuredRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + root, err := bridge.resolveWorkspaceRootForFileAccess() + if err != nil { + t.Fatalf("resolve configured workspace root: %v", err) + } + if root != filepath.Clean(configuredRoot) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(configuredRoot)) + } + + bridgeNoConfig, _ := newGatewayRuntimePortBridge( + context.Background(), + &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, + testSessionStore, + &configManagerStub{cfg: config.Config{Workdir: " \t "}}, + nil, + ) + defer bridgeNoConfig.Close() + + root, err = bridgeNoConfig.resolveWorkspaceRootForFileAccess() + if err != nil { + t.Fatalf("resolve cwd fallback workspace root: %v", err) + } + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + absWd, err := filepath.Abs(wd) + if err != nil { + t.Fatalf("abs wd: %v", err) + } if root != filepath.Clean(absWd) { t.Fatalf("root = %q, want %q", root, filepath.Clean(absWd)) } } +func TestLoadStoredSessionRejectsUnavailableOrUnsupportedSessionStore(t *testing.T) { + bridgeNilStore, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, nil) + defer bridgeNilStore.Close() + + _, err := bridgeNilStore.loadStoredSession(context.Background(), "s-1") + if err == nil || !strings.Contains(err.Error(), "session store is unavailable") { + t.Fatalf("expected unavailable store error, got %v", err) + } + + bridgeUnsupported, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, &bridgeSessionStoreStub{}) + defer bridgeUnsupported.Close() + + _, err = bridgeUnsupported.loadStoredSession(context.Background(), "s-1") + if err == nil || !strings.Contains(err.Error(), "does not support load session") { + t.Fatalf("expected unsupported loader error, got %v", err) + } +} + func TestGatewayRuntimePortBridgeListFilesReadDirFail(t *testing.T) { - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: t.TempDir(), Path: "nonexistent-dir", }) if err == nil { @@ -1894,12 +2033,12 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) { _ = os.MkdirAll(filepath.Join(tmpDir, "Zdir"), 0755) _ = os.MkdirAll(filepath.Join(tmpDir, "adir"), 0755) - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() entries, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, }) if err != nil { t.Fatalf("list files: %v", err) @@ -1922,6 +2061,32 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) { } } +func TestGatewayRuntimePortBridgeListFilesIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(workspaceRoot, "inside.txt"), []byte("inside"), 0644); err != nil { + t.Fatalf("write inside file: %v", err) + } + if err := os.WriteFile(filepath.Join(outsideRoot, "outside.txt"), []byte("outside"), 0644); err != nil { + t.Fatalf("write outside file: %v", err) + } + + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + entries, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + }) + if err != nil { + t.Fatalf("ListFiles() error = %v", err) + } + if len(entries) != 1 || entries[0].Name != "inside.txt" { + t.Fatalf("entries = %+v, want only inside.txt from workspace root", entries) + } +} + func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *testing.T) { tmpDir := t.TempDir() runGitTestCommand(t, tmpDir, "init") @@ -1939,12 +2104,12 @@ func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *te t.Fatalf("write b.txt: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, }) if err != nil { t.Fatalf("ListGitDiffFiles() error = %v", err) @@ -1959,6 +2124,64 @@ func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *te } } +func TestGatewayRuntimePortBridgeListGitDiffFilesIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + runGitTestCommand(t, workspaceRoot, "init") + runGitTestCommand(t, workspaceRoot, "config", "user.name", "NeoCode Test") + runGitTestCommand(t, workspaceRoot, "config", "user.email", "test@example.com") + runGitTestCommand(t, workspaceRoot, "commit", "--allow-empty", "-m", "init") + if err := os.WriteFile(filepath.Join(workspaceRoot, "changed.txt"), []byte("x\n"), 0644); err != nil { + t.Fatalf("write changed file: %v", err) + } + + outsideRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + }) + if err != nil { + t.Fatalf("ListGitDiffFiles() error = %v", err) + } + if !result.InGitRepo { + t.Fatalf("expected workspace root repo to be used, got non-repo result: %+v", result) + } + if result.TotalCount != 1 || len(result.Files) != 1 || result.Files[0].Path != "changed.txt" { + t.Fatalf("unexpected git diff result: %+v", result) + } +} + +func TestGatewayRuntimePortBridgeReadGitDiffFileIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + runGitTestCommand(t, workspaceRoot, "init") + runGitTestCommand(t, workspaceRoot, "config", "user.name", "NeoCode Test") + runGitTestCommand(t, workspaceRoot, "config", "user.email", "test@example.com") + runGitTestCommand(t, workspaceRoot, "commit", "--allow-empty", "-m", "init") + if err := os.WriteFile(filepath.Join(workspaceRoot, "changed.txt"), []byte("line-1\n"), 0644); err != nil { + t.Fatalf("write changed file: %v", err) + } + + outsideRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ReadGitDiffFile(context.Background(), gateway.ReadGitDiffFileInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + Path: "changed.txt", + }) + if err != nil { + t.Fatalf("ReadGitDiffFile() error = %v", err) + } + if result.Path != "changed.txt" || result.Status != string(repository.StatusUntracked) { + t.Fatalf("unexpected read git diff result: %+v", result) + } +} + func runGitTestCommand(t *testing.T, workdir string, args ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", workdir}, args...)...) @@ -1975,12 +2198,12 @@ func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "main.go", }) if err != nil { @@ -1997,18 +2220,45 @@ func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) { } } +func TestGatewayRuntimePortBridgeReadFileIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(workspaceRoot, "main.go"), []byte("package main\n"), 0644); err != nil { + t.Fatalf("write workspace file: %v", err) + } + if err := os.WriteFile(filepath.Join(outsideRoot, "main.go"), []byte("package outside\n"), 0644); err != nil { + t.Fatalf("write outside file: %v", err) + } + + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + Path: "main.go", + }) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if result.Content != "package main\n" { + t.Fatalf("content = %q, want workspace file content", result.Content) + } +} + func TestGatewayRuntimePortBridgeReadFileRejectsDirectory(t *testing.T) { tmpDir := t.TempDir() if err := os.MkdirAll(filepath.Join(tmpDir, "dir"), 0755); err != nil { t.Fatalf("mkdir: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "dir", }) if err == nil || !strings.Contains(err.Error(), "is a directory") { @@ -2019,12 +2269,12 @@ func TestGatewayRuntimePortBridgeReadFileRejectsDirectory(t *testing.T) { func TestGatewayRuntimePortBridgeReadFileRejectsEscapedPath(t *testing.T) { tmpDir := t.TempDir() - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "../secret.txt", }) if err == nil || !strings.Contains(err.Error(), "escapes workdir") { @@ -2040,12 +2290,12 @@ func TestGatewayRuntimePortBridgeReadFileTruncatesLargeFile(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "large.txt", }) if err != nil { @@ -2063,12 +2313,12 @@ func TestGatewayRuntimePortBridgeReadFileMarksBinaryContent(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "bin.dat", }) if err != nil { diff --git a/internal/cli/web_command_test.go b/internal/cli/web_command_test.go index 0c0d23351..dd48daab4 100644 --- a/internal/cli/web_command_test.go +++ b/internal/cli/web_command_test.go @@ -1,15 +1,21 @@ package cli import ( + "bytes" "context" + "encoding/json" "errors" "io/fs" "log" + "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" "testing/fstest" + "time" ) // writeWebCommandTestFile 写入 web 命令测试所需的最小文件内容,避免各测试重复拼装目录。 @@ -286,3 +292,356 @@ func TestRunWebCommandSkipBuildStillUsesEmbeddedAssets(t *testing.T) { t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS") } } + +func TestValidateStaticDirAndResolveOverride(t *testing.T) { + tempDir := t.TempDir() + staticDir := filepath.Join(tempDir, "dist") + if _, err := validateStaticDir(staticDir); err == nil { + t.Fatal("validateStaticDir() error = nil, want missing index.html error") + } + + writeWebCommandTestFile(t, filepath.Join(staticDir, "index.html"), "") + got, err := resolveWebStaticDir(staticDir) + if err != nil { + t.Fatalf("resolveWebStaticDir() error = %v", err) + } + if got != staticDir { + t.Fatalf("resolveWebStaticDir() = %q, want %q", got, staticDir) + } + + dirIndex := filepath.Join(tempDir, "bad-dist", "index.html") + if err := os.MkdirAll(dirIndex, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dirIndex, err) + } + if _, err := validateStaticDir(filepath.Dir(dirIndex)); err == nil || !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("validateStaticDir() error = %v, want directory error", err) + } +} + +func TestIsStaleFrontendBuildBranches(t *testing.T) { + tempDir := t.TempDir() + webDir := filepath.Join(tempDir, "web") + srcDir := filepath.Join(webDir, "src") + distIndex := filepath.Join(webDir, "dist", "index.html") + + if !isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = false, want true when dist is missing") + } + + writeWebCommandTestFile(t, distIndex, "") + writeWebCommandTestFile(t, filepath.Join(webDir, "package.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(webDir, "vite.config.ts"), "export default {}") + writeWebCommandTestFile(t, filepath.Join(webDir, "tsconfig.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(srcDir, "main.ts"), "console.log('ok')") + + distTime := time.Now() + if err := os.Chtimes(distIndex, distTime, distTime); err != nil { + t.Fatalf("chtimes dist: %v", err) + } + olderTime := distTime.Add(-time.Minute) + for _, path := range []string{ + filepath.Join(webDir, "package.json"), + filepath.Join(webDir, "vite.config.ts"), + filepath.Join(webDir, "tsconfig.json"), + filepath.Join(srcDir, "main.ts"), + } { + if err := os.Chtimes(path, olderTime, olderTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } + + if isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = true, want false when dist is newest") + } + + newerTime := distTime.Add(time.Minute) + packageJSON := filepath.Join(webDir, "package.json") + if err := os.Chtimes(packageJSON, newerTime, newerTime); err != nil { + t.Fatalf("chtimes package.json: %v", err) + } + if !isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = false, want true when package.json is newer") + } + + if err := os.Chtimes(packageJSON, olderTime, olderTime); err != nil { + t.Fatalf("restore package.json time: %v", err) + } + srcFile := filepath.Join(srcDir, "main.ts") + if err := os.Chtimes(srcFile, newerTime, newerTime); err != nil { + t.Fatalf("chtimes src: %v", err) + } + if !isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = false, want true when src file is newer") + } +} + +func TestBuildFrontendAndReadGatewayToken(t *testing.T) { + tempDir := t.TempDir() + webDir := filepath.Join(tempDir, "web") + if err := os.MkdirAll(webDir, 0o755); err != nil { + t.Fatalf("mkdir webdir: %v", err) + } + + npmPath := filepath.Join(tempDir, "npm") + script := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "if [ \"$1\" = \"install\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then", + " mkdir -p \"$PWD/dist\"", + " printf '' > \"$PWD/dist/index.html\"", + " exit 0", + "fi", + "exit 1", + }, "\n") + if err := os.WriteFile(npmPath, []byte(script), 0o755); err != nil { + t.Fatalf("write npm stub: %v", err) + } + stubWebCommandHooks(t, nil, nil, func(string) (string, error) { + return npmPath, nil + }) + + logger := log.New(&bytes.Buffer{}, "", 0) + if err := buildFrontend(webDir, logger); err != nil { + t.Fatalf("buildFrontend() error = %v", err) + } + if _, err := os.Stat(filepath.Join(webDir, "dist", "index.html")); err != nil { + t.Fatalf("built dist/index.html missing: %v", err) + } + + homeDir := filepath.Join(tempDir, "home") + authDir := filepath.Join(homeDir, ".neocode") + if err := os.MkdirAll(authDir, 0o755); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + authData, err := json.Marshal(map[string]string{"token": " secret-token "}) + if err != nil { + t.Fatalf("marshal auth data: %v", err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), authData, 0o644); err != nil { + t.Fatalf("write auth.json: %v", err) + } + originalHome := os.Getenv("HOME") + if err := os.Setenv("HOME", homeDir); err != nil { + t.Fatalf("set HOME: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + if got := readGatewayToken(); got != "secret-token" { + t.Fatalf("readGatewayToken() = %q, want %q", got, "secret-token") + } +} + +func TestWaitForGatewayAndOpenBrowserAndResolveListenAddress(t *testing.T) { + tempDir := t.TempDir() + homeDir := filepath.Join(tempDir, "home") + authDir := filepath.Join(homeDir, ".neocode") + if err := os.MkdirAll(authDir, 0o755); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + authData, err := json.Marshal(map[string]string{"token": "token-123"}) + if err != nil { + t.Fatalf("marshal auth data: %v", err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), authData, 0o644); err != nil { + t.Fatalf("write auth.json: %v", err) + } + originalHome := os.Getenv("HOME") + if err := os.Setenv("HOME", homeDir); err != nil { + t.Fatalf("set HOME: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + + binDir := filepath.Join(tempDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + openLog := filepath.Join(tempDir, "opened-url.txt") + scriptPath := filepath.Join(binDir, "xdg-open") + script := "#!/bin/sh\nprintf '%s' \"$1\" > \"" + openLog + "\"\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write xdg-open stub: %v", err) + } + originalPath := os.Getenv("PATH") + if err := os.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("PATH", originalPath) + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + logger := log.New(&bytes.Buffer{}, "", 0) + waitForGatewayAndOpenBrowser(context.Background(), strings.TrimPrefix(server.URL, "http://"), logger) + + var data []byte + var readErr error + for i := 0; i < 20; i++ { + data, readErr = os.ReadFile(openLog) + if readErr == nil { + break + } + time.Sleep(25 * time.Millisecond) + } + if readErr != nil { + t.Fatalf("read open log: %v", readErr) + } + if got := string(data); got != server.URL+"/?token=token-123" { + t.Fatalf("opened url = %q, want %q", got, server.URL+"/?token=token-123") + } + + if got := resolveWebListenAddress("bad-address"); got != "bad-address" { + t.Fatalf("resolveWebListenAddress() = %q, want original invalid address", got) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + occupied := listener.Addr().String() + resolved := resolveWebListenAddress(occupied) + if resolved == occupied { + t.Fatalf("resolveWebListenAddress() = %q, want fallback port", resolved) + } +} + +func TestResolveWebStaticDirCurrentWorkdirAndReadGatewayTokenInvalid(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "dist", "index.html"), "") + stubResolveExecutablePath(t, func() (string, error) { + return "", errors.New("skip executable lookup") + }) + + got, err := resolveWebStaticDir("") + if err != nil { + t.Fatalf("resolveWebStaticDir() error = %v", err) + } + if got != filepath.Join(tempDir, "web", "dist") { + t.Fatalf("resolveWebStaticDir() = %q, want cwd web/dist", got) + } + + homeDir := filepath.Join(tempDir, "home") + authDir := filepath.Join(homeDir, ".neocode") + if err := os.MkdirAll(authDir, 0o755); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), []byte("{invalid"), 0o644); err != nil { + t.Fatalf("write invalid auth.json: %v", err) + } + originalHome := os.Getenv("HOME") + if err := os.Setenv("HOME", homeDir); err != nil { + t.Fatalf("set HOME: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + if got := readGatewayToken(); got != "" { + t.Fatalf("readGatewayToken() = %q, want empty on invalid json", got) + } +} + +func TestRunWebCommandFallbackAndSkipBuildErrors(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + stubResolveExecutablePath(t, func() (string, error) { + return "", errors.New("skip executable lookup") + }) + stubWebCommandEmbeddedAssets(t, nil, false) + + err := runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + SkipBuild: true, + OpenBrowser: false, + Workdir: tempDir, + }) + if err == nil || !strings.Contains(err.Error(), "--skip-build is set") { + t.Fatalf("runWebCommand() error = %v, want skip-build missing assets error", err) + } + + writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}") + err = runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + OpenBrowser: false, + Workdir: tempDir, + }) + if err == nil || !strings.Contains(err.Error(), "frontend build failed on this machine") { + t.Fatalf("runWebCommand() error = %v, want build failure error", err) + } +} + +func TestRunWebCommandRebuildsStaleDistAndDefaultsWorkdir(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + webDir := filepath.Join(tempDir, "web") + writeWebCommandTestFile(t, filepath.Join(webDir, "package.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(webDir, "vite.config.ts"), "export default {}") + writeWebCommandTestFile(t, filepath.Join(webDir, "tsconfig.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(webDir, "src", "main.ts"), "console.log('stale')") + writeWebCommandTestFile(t, filepath.Join(webDir, "dist", "index.html"), "") + + distIndex := filepath.Join(webDir, "dist", "index.html") + oldTime := time.Now().Add(-time.Hour) + newTime := time.Now() + if err := os.Chtimes(distIndex, oldTime, oldTime); err != nil { + t.Fatalf("chtimes dist: %v", err) + } + for _, path := range []string{ + filepath.Join(webDir, "package.json"), + filepath.Join(webDir, "vite.config.ts"), + filepath.Join(webDir, "tsconfig.json"), + filepath.Join(webDir, "src", "main.ts"), + } { + if err := os.Chtimes(path, newTime, newTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } + + buildCalled := false + var captured gatewayCommandOptions + sentinelErr := errors.New("stop after start") + stubWebCommandHooks( + t, + func(_ context.Context, options gatewayCommandOptions, _ string, _ fs.FS, _ func(string)) error { + captured = options + return sentinelErr + }, + func(webDir string, _ *log.Logger) error { + buildCalled = true + writeWebCommandTestFile(t, filepath.Join(webDir, "dist", "index.html"), "") + return nil + }, + nil, + ) + + err := runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + OpenBrowser: false, + }) + if !errors.Is(err, sentinelErr) { + t.Fatalf("runWebCommand() error = %v, want sentinel error", err) + } + if !buildCalled { + t.Fatal("runWebCommand() did not rebuild stale frontend dist") + } + if captured.Workdir != tempDir { + t.Fatalf("gateway workdir = %q, want cwd %q", captured.Workdir, tempDir) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 4365c0fb2..3ccdb3a69 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -144,7 +144,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -560,7 +559,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -609,7 +607,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2426,7 +2423,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2828,7 +2826,6 @@ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "*" } @@ -2874,7 +2871,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2886,7 +2882,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2993,7 +2988,6 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -3199,7 +3193,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3366,6 +3359,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -3385,6 +3379,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -3407,6 +3402,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -3422,7 +3418,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -3430,6 +3427,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -3685,7 +3683,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4056,7 +4053,6 @@ "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -4292,6 +4288,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -4432,6 +4429,7 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -4445,6 +4443,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -4501,7 +4500,6 @@ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -4902,7 +4900,6 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5268,7 +5265,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5351,7 +5347,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.2", @@ -5436,7 +5433,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", @@ -5481,6 +5477,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5494,6 +5491,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5509,6 +5507,7 @@ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5522,6 +5521,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -6090,7 +6090,8 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", @@ -6421,7 +6422,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -7088,7 +7088,8 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -7396,6 +7397,7 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -7409,6 +7411,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7424,7 +7427,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -7432,6 +7436,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -7454,14 +7459,16 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -7474,7 +7481,8 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -7488,14 +7496,16 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -7571,6 +7581,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8097,7 +8108,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -8715,8 +8725,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/mime": { "version": "2.6.0", @@ -8938,8 +8947,7 @@ "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -9101,6 +9109,7 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9492,6 +9501,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9507,6 +9517,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9519,7 +9530,8 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/progress": { "version": "2.0.3", @@ -9601,7 +9613,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9614,7 +9625,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9628,7 +9638,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -9706,6 +9717,7 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -9715,7 +9727,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/readdir-glob/node_modules/brace-expansion": { "version": "2.1.0", @@ -9723,6 +9736,7 @@ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -9733,6 +9747,7 @@ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10683,6 +10698,7 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -10823,7 +10839,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10996,7 +11011,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -11247,7 +11261,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -11337,8 +11350,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11364,7 +11376,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11378,7 +11389,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11805,6 +11815,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -11820,6 +11831,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index 5e1ddf32d..1524b17b1 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -21,6 +21,7 @@ vi.mock('@/context/RuntimeProvider', () => ({ describe('Sidebar ProviderModal', () => { beforeEach(() => { + vi.restoreAllMocks() cleanup() mockGatewayAPI = { listMCPServers: vi.fn().mockResolvedValue({ @@ -130,6 +131,7 @@ describe('Sidebar ProviderModal', () => { useWorkspaceStore.setState({ workspaces: [], currentWorkspaceHash: '', + changing: false, switchWorkspace: vi.fn(), renameWorkspace: vi.fn(), deleteWorkspace: vi.fn(), @@ -328,6 +330,50 @@ describe('Sidebar ProviderModal', () => { }) }) + it('disables workspace actions while a workspace change is in flight', () => { + useWorkspaceStore.setState({ + workspaces: [ + { hash: 'w1', path: '/workspace-one', name: 'Workspace One', createdAt: '1', updatedAt: '1' }, + { hash: 'w2', path: '/workspace-two', name: 'Workspace Two', createdAt: '1', updatedAt: '1' }, + ], + currentWorkspaceHash: 'w1', + changing: true, + switchWorkspace: vi.fn(), + } as any) + + const { container } = render() + + expect(screen.getByRole('button', { name: /Workspace One/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /Workspace Two/i })).toBeDisabled() + + const addWorkspaceButton = container.querySelector('.sidebar-section-header .btn') + expect(addWorkspaceButton).toBeInstanceOf(HTMLButtonElement) + expect(addWorkspaceButton as HTMLButtonElement).toBeDisabled() + }) + + it('switches another workspace but does not re-switch the current workspace', async () => { + const switchWorkspace = vi.fn().mockResolvedValue(undefined) + useWorkspaceStore.setState({ + workspaces: [ + { hash: 'w1', path: '/workspace-one', name: 'Workspace One', createdAt: '1', updatedAt: '1' }, + { hash: 'w2', path: '/workspace-two', name: 'Workspace Two', createdAt: '1', updatedAt: '1' }, + ], + currentWorkspaceHash: 'w1', + changing: false, + switchWorkspace, + } as any) + + render() + + fireEvent.click(screen.getByRole('button', { name: /Workspace Two/i })) + await waitFor(() => { + expect(switchWorkspace).toHaveBeenCalledWith('w2', mockGatewayAPI) + }) + + fireEvent.click(screen.getByRole('button', { name: /Workspace One/i })) + expect(switchWorkspace).toHaveBeenCalledTimes(1) + }) + it('immediately dispatches collapsed-rail actions', async () => { const toggleSidebar = vi.fn() const prepareNewChat = vi.fn() diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index b75c5c410..2e2a21573 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -47,6 +47,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { const workspaces = useWorkspaceStore((s) => s.workspaces) const currentWorkspaceHash = useWorkspaceStore((s) => s.currentWorkspaceHash) + const workspaceChanging = useWorkspaceStore((s) => s.changing) const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace) const renameWorkspace = useWorkspaceStore((s) => s.renameWorkspace) const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace) @@ -110,6 +111,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { async function handleSelectWorkspace(hash: string) { if (!gatewayAPI) return + if (useWorkspaceStore.getState().changing) return if (hash !== currentWorkspaceHash) { await switchWorkspace(hash, gatewayAPI) } @@ -139,6 +141,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { async function handleCreateWorkspace(path: string, name?: string) { if (!gatewayAPI || !path.trim()) return + if (useWorkspaceStore.getState().changing) return await createWorkspace(path.trim(), gatewayAPI, name?.trim() || undefined) setCreateWorkspaceOpen(false) } @@ -209,7 +212,13 @@ export default function Sidebar({ collapsed }: SidebarProps) { {/* Section header: label + add workspace */}
工作区 -
@@ -266,6 +275,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { expanded={rowExpanded} isCurrent={isCurrent} isRenaming={isRenaming} + disabled={workspaceChanging} renameValue={workspaceRenameValue} onRenameValueChange={setWorkspaceRenameValue} onCommitRename={() => handleCommitWorkspaceRename(ws.hash)} @@ -438,11 +448,13 @@ function SessionItem({ function WorkspaceRow({ workspace, expanded, isCurrent, isRenaming, renameValue, + disabled, onRenameValueChange, onCommitRename, onCancelRename, onClick, onStartRename, onDelete, }: { workspace: Workspace expanded: boolean; isCurrent: boolean; isRenaming: boolean + disabled: boolean renameValue: string onRenameValueChange: (v: string) => void onCommitRename: () => void @@ -459,7 +471,7 @@ function WorkspaceRow({ onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} > -