From 464182bcbc07bd0e9351bc7688cd69ddbb3c9ec5 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 9 May 2026 08:40:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E6=A0=8F=E6=97=A0=E6=B3=95=E6=BB=91=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../panels/FileChangePanel.test.tsx | 107 +++++++++++++++++- web/src/components/panels/FileChangePanel.tsx | 60 +++++++--- 2 files changed, 147 insertions(+), 20 deletions(-) diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx index 514d70c8..ec904778 100644 --- a/web/src/components/panels/FileChangePanel.test.tsx +++ b/web/src/components/panels/FileChangePanel.test.tsx @@ -151,9 +151,14 @@ describe('FileChangePanel', () => { const panelBody = screen.getByTestId('file-change-panel-body') const scrollArea = screen.getByTestId('changes-scroll-area') + const view = scrollArea.parentElement as HTMLElement + const contentStack = screen.getByTestId('changes-content-stack') expect(panelBody).toHaveStyle({ overflow: 'hidden', minHeight: '0px' }) + expect(view).toHaveStyle({ flex: '1 1 0%', minHeight: '0px', overflow: 'hidden' }) expect(scrollArea).toHaveStyle({ overflow: 'auto', minHeight: '0px', flex: '1 1 0%' }) + expect(scrollArea).not.toHaveStyle({ display: 'flex' }) + expect(contentStack).toHaveStyle({ display: 'flex', flexDirection: 'column', gap: '10px' }) }) it('caps expanded diff blocks so long hunks do not stretch the whole panel', () => { @@ -165,9 +170,11 @@ describe('FileChangePanel', () => { display: 'flex', flexDirection: 'column', }) + expect(screen.getByTestId('change-card-fc-1')).toHaveStyle({ flexShrink: '0' }) expect(screen.getByTestId('diff-hunk-scroller-fc-1-0')).toHaveStyle({ overflowX: 'auto', - overflowY: 'visible', + overflowY: 'hidden', + maxWidth: '100%', }) expect(screen.getByText('line 2 new').parentElement).toHaveStyle({ width: 'max-content', @@ -176,7 +183,21 @@ describe('FileChangePanel', () => { expect(screen.getByText('line 2 new')).not.toHaveStyle({ overflowX: 'auto' }) }) - it('renders multiple hunks as separately scrollable change blocks', () => { + it('forwards vertical wheel events from hunk scrollers to the outer changes scroll area', () => { + render() + + fireEvent.click(screen.getByText('src/a.txt')) + + const scrollArea = screen.getByTestId('changes-scroll-area') as HTMLDivElement + const hunkScroller = screen.getByTestId('diff-hunk-scroller-fc-1-0') + scrollArea.scrollBy = vi.fn() + + fireEvent.wheel(hunkScroller, { deltaY: 120, deltaX: 0 }) + + expect(scrollArea.scrollBy).toHaveBeenCalledWith({ top: 120 }) + }) + + it('keeps multiple hunks inside the outer vertical scroll container', () => { useUIStore.setState({ fileChanges: [ { @@ -215,9 +236,81 @@ describe('FileChangePanel', () => { fireEvent.click(screen.getByText('src/multi.txt')) + expect(screen.getByTestId('changes-scroll-area')).toHaveStyle({ overflow: 'auto' }) expect(screen.getAllByTestId(/diff-hunk-fc-2-/)).toHaveLength(2) - expect(screen.getByTestId('diff-hunk-scroller-fc-2-0')).toHaveStyle({ overflowX: 'auto', overflowY: 'visible' }) - expect(screen.getByTestId('diff-hunk-scroller-fc-2-1')).toHaveStyle({ overflowX: 'auto', overflowY: 'visible' }) + expect(screen.getByTestId('diff-hunk-scroller-fc-2-0')).toHaveStyle({ + overflowX: 'auto', + overflowY: 'hidden', + maxWidth: '100%', + }) + expect(screen.getByTestId('diff-hunk-scroller-fc-2-1')).toHaveStyle({ + overflowX: 'auto', + overflowY: 'hidden', + maxWidth: '100%', + }) + }) + + it('keeps later files in normal document flow when the first file is expanded', () => { + useUIStore.setState({ + fileChanges: [ + { + id: 'fc-3', + path: 'src/first.txt', + status: 'modified', + additions: 3, + deletions: 1, + hunks: [ + { + header: '@@ -1,2 +1,4 @@', + additions: 2, + deletions: 1, + lines: [ + { type: 'header', content: '@@ -1,2 +1,4 @@' }, + { type: 'context', content: 'head' }, + { type: 'del', content: 'old body' }, + { type: 'add', content: 'new body' }, + { type: 'add', content: 'tail' }, + ], + }, + { + header: '@@ -10,0 +13,1 @@', + additions: 1, + deletions: 0, + lines: [ + { type: 'header', content: '@@ -10,0 +13,1 @@' }, + { type: 'add', content: 'after block' }, + ], + }, + ], + }, + { + id: 'fc-4', + path: 'src/second.txt', + status: 'modified', + additions: 1, + deletions: 0, + hunks: [ + { + header: '@@ -0,0 +1,1 @@', + additions: 1, + deletions: 0, + lines: [ + { type: 'header', content: '@@ -0,0 +1,1 @@' }, + { type: 'add', content: 'next file line' }, + ], + }, + ], + }, + ], + } as never) + + render() + + fireEvent.click(screen.getByText('src/first.txt')) + + const contentStack = screen.getByTestId('changes-content-stack') + expect(contentStack).toHaveTextContent('src/second.txt') + expect(screen.getByTestId('change-card-fc-4')).toHaveStyle({ flexShrink: '0' }) }) it('keeps both fixed tabs visible', () => { @@ -231,6 +324,12 @@ describe('FileChangePanel', () => { render() fireEvent.click(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)) + expect(await screen.findByTestId('git-diff-content-stack')).toHaveStyle({ + display: 'flex', + flexDirection: 'column', + gap: '10px', + }) + expect(screen.getByTestId('git-diff-entry-src/main.go')).toHaveStyle({ flexShrink: '0' }) fireEvent.click(await screen.findByTestId('git-diff-entry-src/main.go')) await waitFor(() => { diff --git a/web/src/components/panels/FileChangePanel.tsx b/web/src/components/panels/FileChangePanel.tsx index fde7bd77..1148b462 100644 --- a/web/src/components/panels/FileChangePanel.tsx +++ b/web/src/components/panels/FileChangePanel.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type RefObject, type WheelEvent as ReactWheelEvent } from 'react' import { ChevronRight, Check, FileDiff, Loader2, PanelRightClose, RefreshCw, X } from 'lucide-react' import { useGatewayAPI } from '@/context/RuntimeProvider' import { useWorkspaceStore } from '@/stores/useWorkspaceStore' @@ -160,18 +160,33 @@ function FileChangeItem({ change, expanded, onToggle, + scrollContainerRef, }: { change: FileChange expanded: boolean onToggle: () => void + scrollContainerRef: RefObject }) { const acceptFileChange = useUIStore((state) => state.acceptFileChange) const meta = getChangeStatusMeta(change.status) const reviewed = change.status === 'accepted' || change.status === 'rejected' const displayHunks = getDisplayHunks(change) + // 将 diff 内层收到的纵向滚轮显式转发给外层列表,避免 hover 在 hunk 上时卡住整列滚动。 + const handleHunkWheel = (event: ReactWheelEvent) => { + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { + return + } + const scrollContainer = scrollContainerRef.current + if (!scrollContainer) { + return + } + scrollContainer.scrollBy({ top: event.deltaY }) + event.preventDefault() + } + return ( -
+
@@ -786,7 +809,7 @@ const styles: Record = { viewContainer: { display: 'flex', flexDirection: 'column', - height: '100%', + flex: 1, minHeight: 0, overflow: 'hidden', }, @@ -836,6 +859,8 @@ const styles: Record = { minHeight: 0, overflow: 'auto', padding: 12, + }, + contentStack: { display: 'flex', flexDirection: 'column', gap: 10, @@ -853,6 +878,7 @@ const styles: Record = { borderRadius: 10, overflow: 'hidden', background: 'var(--bg-primary)', + flexShrink: 0, }, changeHeader: { width: '100%', @@ -955,7 +981,8 @@ const styles: Record = { }, hunkScroller: { overflowX: 'auto', - overflowY: 'visible', + overflowY: 'hidden', + maxWidth: '100%', }, diffLine: { display: 'grid', @@ -1038,6 +1065,7 @@ const styles: Record = { color: 'inherit', cursor: 'pointer', textAlign: 'left', + flexShrink: 0, }, gitDiffBadge: { display: 'inline-flex', From 3af62ef45f38ec2edb0e81b52e017f95d17ef194 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 9 May 2026 09:00:58 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8Dgitdiff=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=96=87=E4=BB=B6=E5=A4=B9=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gateway_runtime_bridge_test.go | 47 +++++++++++ internal/repository/git.go | 84 ++++++++++++++++++- internal/repository/git_diff.go | 18 +++- internal/repository/git_diff_test.go | 35 ++++++++ .../repository/repository_coverage_test.go | 79 +++++++++++++++++ .../panels/FileChangePanel.test.tsx | 54 ++++++++++++ 6 files changed, 315 insertions(+), 2 deletions(-) diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index 58750d2c..ad11ba13 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -1782,6 +1783,52 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) { } } +func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *testing.T) { + tmpDir := t.TempDir() + runGitTestCommand(t, tmpDir, "init") + runGitTestCommand(t, tmpDir, "config", "user.name", "NeoCode Test") + runGitTestCommand(t, tmpDir, "config", "user.email", "test@example.com") + runGitTestCommand(t, tmpDir, "commit", "--allow-empty", "-m", "init") + + if err := os.MkdirAll(filepath.Join(tmpDir, "handwrite_res", "nested"), 0o755); err != nil { + t.Fatalf("mkdir handwrite_res: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "handwrite_res", "a.txt"), []byte("a\n"), 0o644); err != nil { + t.Fatalf("write a.txt: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "handwrite_res", "nested", "b.txt"), []byte("b\n"), 0o644); err != nil { + t.Fatalf("write b.txt: %v", err) + } + + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + defer bridge.Close() + + result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{ + SubjectID: testBridgeSubjectID, + Workdir: tmpDir, + }) + if err != nil { + t.Fatalf("ListGitDiffFiles() error = %v", err) + } + if !result.InGitRepo || result.TotalCount != 2 || len(result.Files) != 2 { + t.Fatalf("unexpected git diff summary: %+v", result) + } + gotPaths := []string{result.Files[0].Path, result.Files[1].Path} + wantPaths := []string{"handwrite_res/a.txt", "handwrite_res/nested/b.txt"} + if strings.Join(gotPaths, ",") != strings.Join(wantPaths, ",") { + t.Fatalf("paths = %#v, want %#v", gotPaths, wantPaths) + } +} + +func runGitTestCommand(t *testing.T, workdir string, args ...string) { + t.Helper() + command := exec.Command("git", append([]string{"-C", workdir}, args...)...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(output)) + } +} + func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) { tmpDir := t.TempDir() target := filepath.Join(tmpDir, "main.go") diff --git a/internal/repository/git.go b/internal/repository/git.go index 2523b77b..1f842f73 100644 --- a/internal/repository/git.go +++ b/internal/repository/git.go @@ -8,9 +8,12 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "time" + + "neo-code/internal/security" ) const ( @@ -68,7 +71,16 @@ func (s *Service) loadGitSnapshot(ctx context.Context, workdir string) (gitSnaps return gitSnapshot{}, err } - return parseGitSnapshot(output.Text), nil + snapshot := parseGitSnapshot(output.Text) + expandedEntries, expandErr := expandUntrackedDirectoryEntries(ctx, workdir, snapshot.Entries) + if expandErr != nil { + if isContextError(expandErr) { + return gitSnapshot{}, expandErr + } + return gitSnapshot{}, expandErr + } + snapshot.Entries = expandedEntries + return snapshot, nil } // changedFileSnippet 按固定语义为单个变更条目生成受限片段。 @@ -162,6 +174,76 @@ func parseGitSnapshot(output string) gitSnapshot { return snapshot } +// expandUntrackedDirectoryEntries 将 git status 返回的未跟踪目录展开为具体文件条目,避免后续预览阶段把目录当文件处理。 +func expandUntrackedDirectoryEntries(ctx context.Context, workdir string, entries []gitChangedEntry) ([]gitChangedEntry, error) { + if len(entries) == 0 { + return nil, nil + } + + expanded := make([]gitChangedEntry, 0, len(entries)) + for _, entry := range entries { + if entry.Status != StatusUntracked { + expanded = append(expanded, entry) + continue + } + + resolvedPath, err := resolveUntrackedEntryPath(workdir, entry.Path) + if err != nil { + expanded = append(expanded, entry) + continue + } + + info, err := os.Stat(resolvedPath) + if err != nil { + expanded = append(expanded, entry) + continue + } + if !info.IsDir() { + expanded = append(expanded, entry) + continue + } + + dirEntries, err := listUntrackedDirectoryFiles(ctx, workdir, resolvedPath) + if err != nil { + if isContextError(err) { + return nil, err + } + expanded = append(expanded, entry) + continue + } + expanded = append(expanded, dirEntries...) + } + return expanded, nil +} + +// resolveUntrackedEntryPath 解析未跟踪条目的工作区绝对路径,供目录展开与防御性校验复用。 +func resolveUntrackedEntryPath(workdir string, relativePath string) (string, error) { + return security.ResolveWorkspacePathFromRoot(workdir, relativePath) +} + +// listUntrackedDirectoryFiles 递归列出未跟踪目录下的真实文件,并返回稳定排序后的未跟踪文件条目。 +func listUntrackedDirectoryFiles(ctx context.Context, workdir string, absoluteDir string) ([]gitChangedEntry, error) { + files := make([]gitChangedEntry, 0) + walkErr := walkWorkspaceFiles(ctx, workdir, absoluteDir, func(path string) error { + relativePath, err := filepath.Rel(workdir, path) + if err != nil { + return err + } + files = append(files, gitChangedEntry{ + Path: filepathSlashClean(relativePath), + Status: StatusUntracked, + }) + return nil + }) + if walkErr != nil { + return nil, walkErr + } + sort.Slice(files, func(i int, j int) bool { + return filepath.ToSlash(files[i].Path) < filepath.ToSlash(files[j].Path) + }) + return files, nil +} + // parseBranchLine 解析分支头信息中的分支名与 ahead/behind 计数。 func parseBranchLine(line string) (string, int, int) { line = strings.TrimSpace(line) diff --git a/internal/repository/git_diff.go b/internal/repository/git_diff.go index 72b11db4..2db6bdcc 100644 --- a/internal/repository/git_diff.go +++ b/internal/repository/git_diff.go @@ -43,6 +43,9 @@ func (s *Service) ReadGitDiffFile(ctx context.Context, workdir string, path stri normalizedPath := filepathSlashClean(path) entry, ok := findGitDiffEntry(snapshot.Entries, normalizedPath) if !ok { + if isDirectoryGitDiffPath(root, normalizedPath) { + return GitDiffFileResult{}, fmt.Errorf("repository: git diff path %q is a directory", normalizedPath) + } return GitDiffFileResult{}, fmt.Errorf("repository: git diff file %q not found", normalizedPath) } limit := maxBytes @@ -149,7 +152,7 @@ func (s *Service) readGitDiffOriginal(ctx context.Context, workdir string, relat // readGitDiffModified 读取工作树中的当前文本。 func (s *Service) readGitDiffModified(workdir string, relativePath string, maxBytes int64) (gitDiffReadResult, error) { - target, err := security.ResolveWorkspacePathFromRoot(workdir, relativePath) + target, err := resolveUntrackedEntryPath(workdir, relativePath) if err != nil { return gitDiffReadResult{}, err } @@ -177,6 +180,19 @@ func (s *Service) readGitDiffModified(workdir string, relativePath string, maxBy return result, nil } +// isDirectoryGitDiffPath 判断调用方请求的 Git Diff 路径当前是否是工作区目录,用于返回更明确的错误语义。 +func isDirectoryGitDiffPath(workdir string, relativePath string) bool { + target, err := resolveUntrackedEntryPath(workdir, relativePath) + if err != nil { + return false + } + info, err := os.Stat(target) + if err != nil { + return false + } + return info.IsDir() +} + // buildGitHeadBlobSpec 构造 HEAD blob 读取使用的对象说明。 func buildGitHeadBlobSpec(path string) string { normalized := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(path)), "./") diff --git a/internal/repository/git_diff_test.go b/internal/repository/git_diff_test.go index b875cd8b..5e479592 100644 --- a/internal/repository/git_diff_test.go +++ b/internal/repository/git_diff_test.go @@ -119,4 +119,39 @@ func TestReadGitDiffFile(t *testing.T) { t.Fatalf("expected truncated diff result, got %+v", result) } }) + + t.Run("reads expanded files from untracked directories", func(t *testing.T) { + service := &Service{ + gitRunner: func(ctx context.Context, workdir string, opts GitCommandOptions, args ...string) (GitCommandOutput, error) { + return GitCommandOutput{Text: "## main\x00?? handwrite_res\x00"}, nil + }, + readFile: readFile, + } + workdir := t.TempDir() + mustWriteRepositoryFile(t, filepath.Join(workdir, "handwrite_res", "nested", "result.txt"), "expanded content\n") + + result, err := service.ReadGitDiffFile(context.Background(), workdir, "handwrite_res/nested/result.txt", 1024) + if err != nil { + t.Fatalf("ReadGitDiffFile() error = %v", err) + } + if result.Status != StatusUntracked || result.ModifiedContent != "expanded content\n" { + t.Fatalf("unexpected expanded untracked diff result: %+v", result) + } + }) + + t.Run("returns explicit directory error for git diff directory path", func(t *testing.T) { + service := &Service{ + gitRunner: func(ctx context.Context, workdir string, opts GitCommandOptions, args ...string) (GitCommandOutput, error) { + return GitCommandOutput{Text: "## main\x00?? handwrite_res\x00"}, nil + }, + readFile: readFile, + } + workdir := t.TempDir() + mustWriteRepositoryFile(t, filepath.Join(workdir, "handwrite_res", "nested", "result.txt"), "expanded content\n") + + _, err := service.ReadGitDiffFile(context.Background(), workdir, "handwrite_res", 1024) + if err == nil || !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("expected directory error, got %v", err) + } + }) } diff --git a/internal/repository/repository_coverage_test.go b/internal/repository/repository_coverage_test.go index 3f6e5307..b025de3b 100644 --- a/internal/repository/repository_coverage_test.go +++ b/internal/repository/repository_coverage_test.go @@ -112,6 +112,85 @@ func TestRepositoryServiceSummaryChangedFilesAndRetrieve(t *testing.T) { assertChangedRepositoryFile(t, result.ChangedFiles.Files[6], filepath.Clean("pkg/conflicted.go"), "", StatusConflicted, "") }) + t.Run("untracked directories expand into stable file entries across summary inspect and changed files", func(t *testing.T) { + t.Parallel() + + workdir := t.TempDir() + mustWriteRepositoryFile(t, filepath.Join(workdir, "handwrite_res", "first.txt"), "first\n") + mustWriteRepositoryFile(t, filepath.Join(workdir, "handwrite_res", "nested", "second.txt"), "second\n") + mustWriteRepositoryFile(t, filepath.Join(workdir, "handwrite_res", "node_modules", "ignored.txt"), "ignored\n") + + service := newRepositoryTestService(func(ctx context.Context, dir string, args ...string) (GitCommandOutput, error) { + if strings.Join(args, " ") != "status --porcelain=v1 -z --branch --untracked-files=normal" { + return GitCommandOutput{}, nil + } + return GitCommandOutput{Text: nulJoin("## main", "?? handwrite_res")}, nil + }) + + summary, err := service.Summary(context.Background(), workdir) + if err != nil { + t.Fatalf("Summary() error = %v", err) + } + if summary.ChangedFileCount != 2 { + t.Fatalf("expected expanded summary count 2, got %+v", summary) + } + + inspectResult, err := service.Inspect(context.Background(), workdir, InspectOptions{ChangedFilesLimit: 10}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if inspectResult.Summary.ChangedFileCount != 2 || inspectResult.ChangedFiles.TotalCount != 2 { + t.Fatalf("unexpected inspect result: %+v", inspectResult) + } + if got := []string{ + inspectResult.ChangedFiles.Files[0].Path, + inspectResult.ChangedFiles.Files[1].Path, + }; !slices.Equal(got, []string{ + filepath.Clean("handwrite_res/first.txt"), + filepath.Clean("handwrite_res/nested/second.txt"), + }) { + t.Fatalf("unexpected inspect paths: %#v", got) + } + + changedFiles, err := service.ChangedFiles(context.Background(), workdir, ChangedFilesOptions{}) + if err != nil { + t.Fatalf("ChangedFiles() error = %v", err) + } + if changedFiles.TotalCount != 2 || len(changedFiles.Files) != 2 { + t.Fatalf("unexpected changed files result: %+v", changedFiles) + } + }) + + t.Run("untracked directory containing only skipped noise does not emit pseudo files", func(t *testing.T) { + t.Parallel() + + workdir := t.TempDir() + mustWriteRepositoryFile(t, filepath.Join(workdir, "handwrite_res", "node_modules", "ignored.txt"), "ignored\n") + + service := newRepositoryTestService(func(ctx context.Context, dir string, args ...string) (GitCommandOutput, error) { + if strings.Join(args, " ") != "status --porcelain=v1 -z --branch --untracked-files=normal" { + return GitCommandOutput{}, nil + } + return GitCommandOutput{Text: nulJoin("## main", "?? handwrite_res")}, nil + }) + + summary, err := service.Summary(context.Background(), workdir) + if err != nil { + t.Fatalf("Summary() error = %v", err) + } + if summary.ChangedFileCount != 0 { + t.Fatalf("expected skipped directory to contribute no files, got %+v", summary) + } + + changedFiles, err := service.ChangedFiles(context.Background(), workdir, ChangedFilesOptions{}) + if err != nil { + t.Fatalf("ChangedFiles() error = %v", err) + } + if changedFiles.TotalCount != 0 || len(changedFiles.Files) != 0 { + t.Fatalf("expected no changed files, got %+v", changedFiles) + } + }) + t.Run("changed files truncation and snippet filters", func(t *testing.T) { t.Parallel() diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx index ec904778..d0d95bbb 100644 --- a/web/src/components/panels/FileChangePanel.test.tsx +++ b/web/src/components/panels/FileChangePanel.test.tsx @@ -342,6 +342,60 @@ describe('FileChangePanel', () => { expect(preview.textContent).toContain('before::after') }) + it('opens expanded nested untracked files from the git diff list', async () => { + mockGatewayAPI.listGitDiffFiles.mockResolvedValue({ + payload: { + in_git_repo: true, + branch: 'main', + ahead: 0, + behind: 0, + truncated: false, + total_count: 1, + files: [ + { + path: 'handwrite_res/nested/result.txt', + status: 'untracked', + }, + ], + }, + }) + mockGatewayAPI.readGitDiffFile.mockResolvedValue({ + payload: { + path: 'handwrite_res/nested/result.txt', + status: 'untracked', + original_content: '', + modified_content: 'expanded', + size_original: 0, + size_modified: 8, + }, + }) + useUIStore.setState({ + gitDiffSummary: { + in_git_repo: true, + branch: 'main', + ahead: 0, + behind: 0, + truncated: false, + total_count: 1, + files: [ + { + path: 'handwrite_res/nested/result.txt', + status: 'untracked', + }, + ], + }, + } as never) + + render() + + fireEvent.click(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)) + fireEvent.click(await screen.findByTestId('git-diff-entry-handwrite_res/nested/result.txt')) + + await waitFor(() => { + expect(mockGatewayAPI.readGitDiffFile).toHaveBeenCalledWith({ path: 'handwrite_res/nested/result.txt' }) + }) + }) + it('renders the Monaco-based preview host for loaded text files', async () => { useUIStore.setState({ previewTabs: [ From c66dc2fe5b00af79f1d70059e53fb20379da5e1a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 9 May 2026 09:20:22 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E4=BA=92=E4=B8=B2=E6=98=BE=E7=A4=BA=EF=BC=8C=E8=BF=98?= =?UTF-8?q?=E6=9C=89=E6=97=B6=E9=97=B4=E4=B9=B1=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/layout/AppLayout.tsx | 4 +- .../components/panels/FileTreePanel.test.tsx | 143 +++++++++++++++++- web/src/components/panels/FileTreePanel.tsx | 76 ++++++++-- web/src/utils/format.test.ts | 32 ++++ web/src/utils/format.ts | 44 +++--- 5 files changed, 267 insertions(+), 32 deletions(-) create mode 100644 web/src/utils/format.test.ts diff --git a/web/src/components/layout/AppLayout.tsx b/web/src/components/layout/AppLayout.tsx index 2fca60c2..32a34483 100644 --- a/web/src/components/layout/AppLayout.tsx +++ b/web/src/components/layout/AppLayout.tsx @@ -8,6 +8,7 @@ import ToastContainer from '@/components/ui/ToastContainer' import { ErrorBoundary } from '@/components/ErrorBoundary' import { useUIStore } from '@/stores/useUIStore' import { useSessionStore } from '@/stores/useSessionStore' +import { useWorkspaceStore } from '@/stores/useWorkspaceStore' interface AppLayoutProps { shellMode?: 'electron' | 'browser' @@ -53,6 +54,7 @@ export default function AppLayout({ shellMode = 'electron' }: AppLayoutProps) { const fileTreePanelOpen = useUIStore((s) => s.fileTreePanelOpen) const fileTreePanelWidth = useUIStore((s) => s.fileTreePanelWidth) const setFileTreePanelWidth = useUIStore((s) => s.setFileTreePanelWidth) + const currentWorkspaceHash = useWorkspaceStore((s) => s.currentWorkspaceHash) const sidebarResize = useResize(useCallback((delta) => { setSidebarWidth(Math.max(180, Math.min(500, sidebarWidth + delta))) @@ -113,7 +115,7 @@ export default function AppLayout({ shellMode = 'electron' }: AppLayoutProps) { {fileTreePanelOpen && (
- +
)}
diff --git a/web/src/components/panels/FileTreePanel.test.tsx b/web/src/components/panels/FileTreePanel.test.tsx index 054984af..dd4e6a9b 100644 --- a/web/src/components/panels/FileTreePanel.test.tsx +++ b/web/src/components/panels/FileTreePanel.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import FileTreePanel from './FileTreePanel' import { useUIStore } from '@/stores/useUIStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore' @@ -27,7 +27,10 @@ describe('FileTreePanel', () => { } as never) useWorkspaceStore.setState({ currentWorkspaceHash: 'ws-1', - workspaces: [{ hash: 'ws-1', name: 'demo', path: 'D:/demo', createdAt: '', updatedAt: '' }], + workspaces: [ + { hash: 'ws-1', name: 'demo-1', path: 'D:/demo-1', createdAt: '', updatedAt: '' }, + { hash: 'ws-2', name: 'demo-2', path: 'D:/demo-2', createdAt: '', updatedAt: '' }, + ], } as never) }) @@ -121,4 +124,140 @@ describe('FileTreePanel', () => { const scrollArea = screen.getByTestId('file-tree-scroll-area') expect(scrollArea).toHaveStyle({ overflowY: 'auto', minHeight: '0px', flex: '1 1 0%' }) }) + + it('reloads the root tree when the workspace changes', async () => { + mockGatewayAPI = { + listFiles: vi.fn().mockImplementation(async ({ path = '' }: { path?: string }) => { + const workspaceHash = useWorkspaceStore.getState().currentWorkspaceHash + if (path) { + return { payload: { files: [] } } + } + if (workspaceHash === 'ws-1') { + return { + payload: { + files: [{ name: 'old.go', path: 'old.go', is_dir: false }], + }, + } + } + return { + payload: { + files: [{ name: 'new.ts', path: 'new.ts', is_dir: false }], + }, + } + }), + readFile: vi.fn(), + } + + render() + + await waitFor(() => { + expect(screen.getByText('old.go')).toBeTruthy() + }) + + act(() => { + useWorkspaceStore.setState({ currentWorkspaceHash: 'ws-2' } as never) + }) + + await waitFor(() => { + expect(screen.getByText('new.ts')).toBeTruthy() + }) + expect(screen.queryByText('old.go')).toBeNull() + }) + + it('does not keep expanded directory state across workspace switches', async () => { + mockGatewayAPI = { + listFiles: vi.fn().mockImplementation(async ({ path = '' }: { path?: string }) => { + const workspaceHash = useWorkspaceStore.getState().currentWorkspaceHash + if (!path) { + return { + payload: { + files: [{ name: 'src', path: 'src', is_dir: true }], + }, + } + } + + if (workspaceHash === 'ws-1') { + return { + payload: { + files: [{ name: 'old.txt', path: 'src/old.txt', is_dir: false }], + }, + } + } + return { + payload: { + files: [{ name: 'new.txt', path: 'src/new.txt', is_dir: false }], + }, + } + }), + readFile: vi.fn(), + } + + render() + + await waitFor(() => { + expect(screen.getByText('src')).toBeTruthy() + }) + + fireEvent.click(screen.getByText('src')) + + await waitFor(() => { + expect(screen.getByText('old.txt')).toBeTruthy() + }) + + act(() => { + useWorkspaceStore.setState({ currentWorkspaceHash: 'ws-2' } as never) + }) + + await waitFor(() => { + expect(screen.getByText('src')).toBeTruthy() + }) + expect(screen.queryByText('old.txt')).toBeNull() + expect(screen.queryByText('new.txt')).toBeNull() + }) + + it('ignores late root responses from the previous workspace', async () => { + let resolveOldRoot: ((value: { payload: { files: Array<{ name: string; path: string; is_dir: boolean }> } }) => void) | null = null + + mockGatewayAPI = { + listFiles: vi.fn().mockImplementation(({ path = '' }: { path?: string }) => { + const workspaceHash = useWorkspaceStore.getState().currentWorkspaceHash + if (path) { + return Promise.resolve({ payload: { files: [] } }) + } + if (workspaceHash === 'ws-1') { + return new Promise((resolve) => { + resolveOldRoot = resolve + }) + } + return Promise.resolve({ + payload: { + files: [{ name: 'new.ts', path: 'new.ts', is_dir: false }], + }, + }) + }), + readFile: vi.fn(), + } + + render() + + act(() => { + useWorkspaceStore.setState({ currentWorkspaceHash: 'ws-2' } as never) + }) + + await waitFor(() => { + expect(screen.getByText('new.ts')).toBeTruthy() + }) + + await act(async () => { + resolveOldRoot?.({ + payload: { + files: [{ name: 'old.go', path: 'old.go', is_dir: false }], + }, + }) + await Promise.resolve() + }) + + expect(screen.getByText('new.ts')).toBeTruthy() + expect(screen.queryByText('old.go')).toBeNull() + }) }) diff --git a/web/src/components/panels/FileTreePanel.tsx b/web/src/components/panels/FileTreePanel.tsx index 9a67b2b9..ebfbd28b 100644 --- a/web/src/components/panels/FileTreePanel.tsx +++ b/web/src/components/panels/FileTreePanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useUIStore, type FilePreviewTab } from '@/stores/useUIStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import { useGatewayAPI } from '@/context/RuntimeProvider' @@ -38,7 +38,7 @@ function buildFileTree(entries: FileEntry[]): FileTreeNode[] { const rootNodes: FileTreeNode[] = [] const dirMap = new Map() - // 先创建所有目录节点 + // 先创建所有目录节点。 for (const entry of entries) { if (entry.is_dir) { const node: FileTreeNode = { entry, children: [] } @@ -46,7 +46,7 @@ function buildFileTree(entries: FileEntry[]): FileTreeNode[] { } } - // 再分配所有节点到父目录 + // 再把所有节点归到父目录下。 for (const entry of entries) { const parentPath = entry.path.split('/').slice(0, -1).join('/') if (parentPath && dirMap.has(parentPath)) { @@ -57,14 +57,14 @@ function buildFileTree(entries: FileEntry[]): FileTreeNode[] { parent.children!.push({ entry }) } } else if (!parentPath) { - // 根级别节点 + // 根级节点。 if (entry.is_dir) { rootNodes.push(dirMap.get(entry.path)!) } else { rootNodes.push({ entry }) } } else { - // 父目录缺失:作为根节点挂载(容错处理,避免节点丢失) + // 父目录缺失时挂到根级,避免节点被丢弃。 if (entry.is_dir) { rootNodes.push(dirMap.get(entry.path)!) } else { @@ -99,6 +99,7 @@ function FileTreeItem({ node, depth = 0, dirCache, onLoadDir, onOpenFile }: File await onOpenFile(node.entry.path) return } + if (!isLoaded) { setLocalLoading(true) try { @@ -190,13 +191,36 @@ export default function FileTreePanel() { const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [currentPath, setCurrentPath] = useState('') + const activeWorkspaceRef = useRef(currentWorkspaceHash) + const rootRequestTokenRef = useRef(0) + const mountedRef = useRef(true) + + useEffect(() => { + return () => { + mountedRef.current = false + } + }, []) + + useEffect(() => { + activeWorkspaceRef.current = currentWorkspaceHash + }, [currentWorkspaceHash]) const loadDir = useCallback(async (path: string) => { if (!gatewayAPI) return + + const requestWorkspaceHash = activeWorkspaceRef.current + try { const result = await gatewayAPI.listFiles({ path }) + if (!mountedRef.current || activeWorkspaceRef.current !== requestWorkspaceHash) { + return + } + const nodes = buildFileTree(result.payload.files) setDirCache((prev) => { + if (!mountedRef.current || activeWorkspaceRef.current !== requestWorkspaceHash) { + return prev + } const next = new Map(prev) next.set(path, nodes) return next @@ -207,24 +231,55 @@ export default function FileTreePanel() { } }, [gatewayAPI]) - const loadRoot = useCallback(async () => { + // loadRoot 在工作区切换时重置文件树状态,并丢弃旧工作区的晚到响应。 + const loadRoot = useCallback(async (workspaceHash: string) => { if (!gatewayAPI) return + + const requestToken = rootRequestTokenRef.current + 1 + rootRequestTokenRef.current = requestToken + + setRootNodes([]) + setDirCache(new Map()) + setCurrentPath('') setLoading(true) setError('') + try { const result = await gatewayAPI.listFiles({ path: '' }) + if ( + !mountedRef.current || + activeWorkspaceRef.current !== workspaceHash || + rootRequestTokenRef.current !== requestToken + ) { + return + } + setRootNodes(buildFileTree(result.payload.files)) setCurrentPath('') } catch (err) { + if ( + !mountedRef.current || + activeWorkspaceRef.current !== workspaceHash || + rootRequestTokenRef.current !== requestToken + ) { + return + } + const msg = err instanceof Error ? err.message : 'Failed to load file list' setError(msg) console.error('listFiles failed:', err) } finally { - setLoading(false) + if ( + mountedRef.current && + activeWorkspaceRef.current === workspaceHash && + rootRequestTokenRef.current === requestToken + ) { + setLoading(false) + } } }, [gatewayAPI]) - // openFilePreview 负责复用/创建文件标签,并按需拉取只读预览内容。 + // openFilePreview 负责复用或创建文件标签,并按需拉取只读预览内容。 const openFilePreview = useCallback(async (path: string) => { if (!gatewayAPI) return @@ -248,8 +303,9 @@ export default function FileTreePanel() { }, [gatewayAPI, openPreviewTab, setPreviewTabContent, setPreviewTabError, setPreviewTabLoading]) useEffect(() => { - loadRoot() - }, [loadRoot]) + activeWorkspaceRef.current = currentWorkspaceHash + void loadRoot(currentWorkspaceHash) + }, [currentWorkspaceHash, loadRoot]) return (
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts new file mode 100644 index 00000000..5187b267 --- /dev/null +++ b/web/src/utils/format.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { relativeTime } from './format' + +describe('relativeTime', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-09T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns empty string for empty input', () => { + expect(relativeTime('')).toBe('') + }) + + it('returns the original value for invalid timestamps', () => { + expect(relativeTime('not-a-time')).toBe('not-a-time') + }) + + it('returns 刚刚 for times within one minute', () => { + expect(relativeTime('2026-05-09T11:59:30Z')).toBe('刚刚') + }) + + it('returns minute, hour, day, and month suffixes for older timestamps', () => { + expect(relativeTime('2026-05-09T11:55:00Z')).toBe('5m') + expect(relativeTime('2026-05-09T09:00:00Z')).toBe('3h') + expect(relativeTime('2026-05-06T12:00:00Z')).toBe('3d') + expect(relativeTime('2026-03-05T12:00:00Z')).toBe('2mo') + }) +}) diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts index 0996efb2..011170af 100644 --- a/web/src/utils/format.ts +++ b/web/src/utils/format.ts @@ -10,7 +10,7 @@ export function formatDate(date: Date | string): string { return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) } -/** 格式化 Token 数量(带 K/M 后缀) */ +/** 格式化 Token 数量,带 K/M 后缀 */ export function formatTokenCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` @@ -19,25 +19,31 @@ export function formatTokenCount(n: number): string { /** 截断文本 */ export function truncate(text: string, maxLen: number): string { - if (text.length <= maxLen) return text - return text.slice(0, maxLen - 3) + '...' + if (text.length <= maxLen) return text + return text.slice(0, maxLen - 3) + '...' } -/** 绠€鐭浉瀵规椂闂?*/ +/** 返回简短的相对时间文案 */ export function relativeTime(time: string): string { - if (!time) return '' - const d = new Date(time) - if (isNaN(d.getTime())) return time - const now = Date.now() - const diff = now - d.getTime() - const sec = Math.floor(diff / 1000) - if (sec < 60) return '鍒氬垰' - const min = Math.floor(sec / 60) - if (min < 60) return `${min}m` - const hr = Math.floor(min / 60) - if (hr < 24) return `${hr}h` - const day = Math.floor(hr / 24) - if (day < 30) return `${day}d` - const mon = Math.floor(day / 30) - return `${mon}mo` + if (!time) return '' + + const d = new Date(time) + if (isNaN(d.getTime())) return time + + const now = Date.now() + const diff = now - d.getTime() + const sec = Math.floor(diff / 1000) + if (sec < 60) return '刚刚' + + const min = Math.floor(sec / 60) + if (min < 60) return `${min}m` + + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h` + + const day = Math.floor(hr / 24) + if (day < 30) return `${day}d` + + const mon = Math.floor(day / 30) + return `${mon}mo` } From df6fb1e5ba09b4ebedbe752764ad79a5f47301a5 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 9 May 2026 10:32:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor(web):=E7=BE=8E=E5=8C=96=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../panels/FileChangePanel.test.tsx | 124 +++++++- web/src/components/panels/FileChangePanel.tsx | 282 +++++++++++++----- 2 files changed, 325 insertions(+), 81 deletions(-) diff --git a/web/src/components/panels/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx index d0d95bbb..097e0957 100644 --- a/web/src/components/panels/FileChangePanel.test.tsx +++ b/web/src/components/panels/FileChangePanel.test.tsx @@ -313,11 +313,13 @@ describe('FileChangePanel', () => { expect(screen.getByTestId('change-card-fc-4')).toHaveStyle({ flexShrink: '0' }) }) - it('keeps both fixed tabs visible', () => { + it('keeps both fixed tabs visible in the primary switcher and hides secondary chips by default', () => { render() - expect(screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`)).toBeTruthy() - expect(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)).toBeTruthy() + const primaryTabs = screen.getByTestId('preview-primary-tabs') + expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`)) + expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)) + expect(screen.queryByTestId('preview-secondary-tabs')).toBeNull() }) it('opens a git diff file tab from the fixed git diff view', async () => { @@ -342,6 +344,122 @@ describe('FileChangePanel', () => { expect(preview.textContent).toContain('before::after') }) + it('renders file preview tabs in the secondary chip row instead of mixing them into the primary switcher', () => { + useUIStore.setState({ + previewTabs: [ + { + id: CHANGES_PREVIEW_TAB_ID, + kind: 'changes', + title: '鏂囦欢鍙樻洿', + closable: false, + }, + { + id: GIT_DIFF_PREVIEW_TAB_ID, + kind: 'git-diff', + title: 'Git Diff', + closable: false, + }, + { + id: 'file:cmd/neocode/main.go', + kind: 'file', + title: 'main.go', + closable: true, + path: 'cmd/neocode/main.go', + content: 'package main', + loading: false, + loaded: true, + error: '', + truncated: false, + is_binary: false, + }, + ], + activePreviewTabId: 'file:cmd/neocode/main.go', + } as never) + + render() + + const primaryTabs = screen.getByTestId('preview-primary-tabs') + const secondaryTabs = screen.getByTestId('preview-secondary-tabs') + expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`)) + expect(primaryTabs).toContainElement(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)) + expect(primaryTabs).not.toContainElement(screen.getByTestId('preview-tab-file:cmd/neocode/main.go')) + expect(secondaryTabs).toContainElement(screen.getByTestId('preview-tab-file:cmd/neocode/main.go')) + }) + + it('marks the fixed git diff switcher as context-active while a git diff file chip is selected', async () => { + render() + + fireEvent.click(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)) + fireEvent.click(await screen.findByTestId('git-diff-entry-src/main.go')) + + await waitFor(() => { + expect(mockGatewayAPI.readGitDiffFile).toHaveBeenCalledWith({ path: 'src/main.go' }) + }) + + expect(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)).toHaveAttribute('data-context-active', 'true') + expect(screen.getByTestId('preview-tab-git-diff-file:src/main.go')).toHaveAttribute('aria-selected', 'true') + }) + + it('falls back to the fixed git diff tab after closing the active git diff file chip', async () => { + render() + + fireEvent.click(screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`)) + fireEvent.click(await screen.findByTestId('git-diff-entry-src/main.go')) + + await waitFor(() => { + expect(screen.getByTestId('preview-tab-git-diff-file:src/main.go')).toBeTruthy() + }) + + fireEvent.click(screen.getByTestId('preview-tab-close-git-diff-file:src/main.go')) + + expect(useUIStore.getState().activePreviewTabId).toBe(GIT_DIFF_PREVIEW_TAB_ID) + expect(screen.queryByTestId('preview-tab-git-diff-file:src/main.go')).toBeNull() + }) + + it('keeps keyboard roving focus across both fixed tabs and file chips', () => { + useUIStore.setState({ + previewTabs: [ + { + id: CHANGES_PREVIEW_TAB_ID, + kind: 'changes', + title: '鏂囦欢鍙樻洿', + closable: false, + }, + { + id: GIT_DIFF_PREVIEW_TAB_ID, + kind: 'git-diff', + title: 'Git Diff', + closable: false, + }, + { + id: 'file:cmd/neocode/main.go', + kind: 'file', + title: 'main.go', + closable: true, + path: 'cmd/neocode/main.go', + content: 'package main', + loading: false, + loaded: true, + error: '', + truncated: false, + is_binary: false, + }, + ], + activePreviewTabId: CHANGES_PREVIEW_TAB_ID, + } as never) + + render() + + const changesTab = screen.getByTestId(`preview-tab-${CHANGES_PREVIEW_TAB_ID}`) + changesTab.focus() + fireEvent.keyDown(changesTab, { key: 'ArrowRight' }) + expect(useUIStore.getState().activePreviewTabId).toBe(GIT_DIFF_PREVIEW_TAB_ID) + + const gitDiffTab = screen.getByTestId(`preview-tab-${GIT_DIFF_PREVIEW_TAB_ID}`) + fireEvent.keyDown(gitDiffTab, { key: 'ArrowRight' }) + expect(useUIStore.getState().activePreviewTabId).toBe('file:cmd/neocode/main.go') + }) + it('opens expanded nested untracked files from the git diff list', async () => { mockGatewayAPI.listGitDiffFiles.mockResolvedValue({ payload: { diff --git a/web/src/components/panels/FileChangePanel.tsx b/web/src/components/panels/FileChangePanel.tsx index 1148b462..b8947a48 100644 --- a/web/src/components/panels/FileChangePanel.tsx +++ b/web/src/components/panels/FileChangePanel.tsx @@ -110,6 +110,13 @@ function getPreviewBadge(tab: PreviewTab, fileChanges: FileChange[]) { return null } +function getContextPreviewTabID(activeTab: PreviewTab | undefined) { + if (!activeTab) return CHANGES_PREVIEW_TAB_ID + if (activeTab.kind === 'file') return CHANGES_PREVIEW_TAB_ID + if (activeTab.kind === 'git-diff-file') return GIT_DIFF_PREVIEW_TAB_ID + return activeTab.id +} + function formatPreviewMeta(tab: FilePreviewTab) { const segments: string[] = [] if (typeof tab.size === 'number') { @@ -484,6 +491,22 @@ function PreviewTabStrip({ onClose: (id: string) => void }) { const tabRefs = useRef>({}) + const fixedTabs = useMemo( + () => tabs.filter((tab) => tab.kind === 'changes' || tab.kind === 'git-diff'), + [tabs], + ) + const previewFileTabs = useMemo( + () => tabs.filter((tab) => tab.kind === 'file' || tab.kind === 'git-diff-file'), + [tabs], + ) + const activeTab = useMemo( + () => tabs.find((tab) => tab.id === activeTabId), + [activeTabId, tabs], + ) + const contextTabId = useMemo( + () => getContextPreviewTabID(activeTab), + [activeTab], + ) useEffect(() => { const active = tabRefs.current[activeTabId] @@ -521,53 +544,107 @@ function PreviewTabStrip({ } } - return ( -
- {tabs.map((tab, index) => { - const badge = getPreviewBadge(tab, fileChanges) - const active = tab.id === activeTabId + const renderTab = (tab: PreviewTab, index: number, appearance: 'switcher' | 'chip') => { + const badge = getPreviewBadge(tab, fileChanges) + const active = tab.id === activeTabId + const contextActive = appearance === 'switcher' && !active && tab.id === contextTabId + const title = tab.kind === 'file' || tab.kind === 'git-diff-file' ? tab.path : tab.title + + if (appearance === 'switcher') { + return ( + + ) + } - return ( -
+ + {tab.closable && ( + - {tab.closable && ( - - )} -
- ) - })} + + + )} +
+ ) + } + + return ( +
+
+
+ {fixedTabs.map((tab) => { + const index = tabs.findIndex((entry) => entry.id === tab.id) + return renderTab(tab, index, 'switcher') + })} +
+
+ {previewFileTabs.length > 0 && ( +
+ {previewFileTabs.map((tab) => { + const index = tabs.findIndex((entry) => entry.id === tab.id) + return renderTab(tab, index, 'chip') + })} +
+ )}
) } @@ -709,89 +786,138 @@ const styles: Record = { }, dockHeader: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: 8, - padding: '8px 10px 0', + padding: '8px 10px 10px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0, }, - tabStrip: { + tabStack: { + display: 'flex', + flexDirection: 'column', + gap: 6, + minWidth: 0, + flex: 1, + }, + switcherRow: { display: 'flex', - alignItems: 'stretch', + alignItems: 'center', + minWidth: 0, + }, + switcherRail: { + display: 'flex', + alignItems: 'center', gap: 2, + minWidth: 0, + padding: 2, + borderRadius: 'var(--radius-md)', + background: 'rgba(148, 163, 184, 0.08)', + }, + switcherButton: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 28, + padding: '0 12px', + border: 'none', + borderRadius: 'var(--radius-sm)', + background: 'transparent', + color: 'var(--text-secondary)', + minWidth: 0, + cursor: 'pointer', + transition: 'all var(--duration-fast) var(--ease-out)', + }, + switcherButtonActive: { + background: 'var(--bg-active)', + color: 'var(--text-primary)', + boxShadow: 'inset 0 -1px 0 var(--accent)', + }, + switcherButtonContext: { + background: 'rgba(148, 163, 184, 0.08)', + color: 'var(--text-primary)', + boxShadow: 'inset 0 -1px 0 var(--border-primary)', + }, + switcherLabel: { + fontFamily: 'var(--font-ui)', + fontSize: 12, + fontWeight: 500, + }, + chipRow: { + display: 'flex', + alignItems: 'center', + gap: 6, overflowX: 'auto', - paddingBottom: 8, - flex: 1, + paddingBottom: 2, }, - tabItem: { + chipItem: { display: 'flex', alignItems: 'center', minWidth: 0, maxWidth: 220, - borderRadius: '10px 10px 0 0', - borderTop: '1px solid transparent', - borderLeft: '1px solid transparent', - borderRight: '1px solid transparent', - borderBottom: 'none', + height: 26, + paddingLeft: 2, + borderRadius: 'var(--radius-full)', + border: '1px solid transparent', background: 'rgba(148, 163, 184, 0.08)', color: 'var(--text-secondary)', + flexShrink: 0, }, - tabItemActive: { - background: 'var(--bg-primary)', - borderTopColor: 'var(--border-primary)', - borderLeftColor: 'var(--border-primary)', - borderRightColor: 'var(--border-primary)', + chipItemActive: { + background: 'var(--bg-active)', + borderColor: 'var(--border-primary)', color: 'var(--text-primary)', - boxShadow: '0 -1px 0 rgba(255,255,255,0.03) inset', }, - tabButton: { + chipButton: { display: 'flex', alignItems: 'center', - gap: 6, + gap: 8, minWidth: 0, maxWidth: 188, - padding: '8px 10px 7px', + height: '100%', + padding: '0 10px', border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer', textAlign: 'left', }, - tabBadge: { - fontSize: 10, - fontWeight: 700, + chipDot: { + width: 6, + height: 6, + borderRadius: 'var(--radius-full)', flexShrink: 0, - fontFamily: 'var(--font-ui)', }, - tabLabel: { + chipLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--font-ui)', - fontSize: 12, + fontSize: 11, fontWeight: 500, }, - tabCloseButton: { + chipCloseButton: { display: 'flex', alignItems: 'center', justifyContent: 'center', - width: 22, - height: 22, - marginRight: 6, + width: 18, + height: 18, + marginRight: 4, border: 'none', - borderRadius: '999px', + borderRadius: 'var(--radius-full)', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', flexShrink: 0, }, + chipCloseButtonActive: { + color: 'var(--text-secondary)', + }, closeBtn: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, - marginBottom: 8, borderRadius: 'var(--radius-sm)', border: 'none', background: 'transparent', From 8f72443500a5673bc0f6563cb519728fbfecf84b Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 9 May 2026 10:49:20 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E7=8E=B0=E5=9C=A8ci=E4=BC=9A?= =?UTF-8?q?=E5=9C=A8=E5=8F=91=E5=B8=83=E5=89=8D=E8=87=AA=E5=8A=A8=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=B9=B6=E5=86=85=E5=B5=8Cdist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 13 +++- .goreleaser.yaml | 14 +--- README.en.md | 8 +++ README.md | 2 +- internal/cli/release_config_test.go | 92 +++++++++++++++++++++++++ internal/cli/web_command.go | 63 +++++++++++------- internal/cli/web_command_test.go | 100 ++++++++++++++++++++++++++++ www/en/guide/install.md | 8 +++ www/guide/install.md | 4 +- www/guide/web-ui.md | 6 +- 10 files changed, 267 insertions(+), 43 deletions(-) create mode 100644 internal/cli/release_config_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e317d60..f2194183 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,17 @@ jobs: with: go-version-file: 'go.mod' # 💡 修复点 1:让 Action 自动跟随项目真实的 Go 版本 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build web dist + working-directory: web + run: | + npm ci + npm run build + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: @@ -35,4 +46,4 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 💡 修复点 2:补回本仓库的内置鉴权令牌 - TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} \ No newline at end of file + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 37187dbc..568c5477 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,6 +10,8 @@ builds: - id: neocode env: - CGO_ENABLED=0 + tags: + - webembed ldflags: - -s -w -X 'neo-code/internal/version.Version={{.Version}}' goos: @@ -52,18 +54,6 @@ archives: {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} - files: - - web/package.json - - web/package-lock.json - - web/index.html - - web/components.json - - web/tsconfig.json - - web/tsconfig.app.json - - web/tsconfig.node.json - - web/vite.config.ts - - web/src/**/* - - web/scripts/**/* - - web/vite-plugins/**/* - id: neocode-gateway ids: diff --git a/README.en.md b/README.en.md index 7d563187..fb5af637 100644 --- a/README.en.md +++ b/README.en.md @@ -113,6 +113,14 @@ Then start with your workspace: neocode --workdir /path/to/your/project ``` +To launch the browser-based Web UI: + +```bash +neocode web +``` + +Tagged release builds already embed `web/dist` into the `neocode` binary, so the target machine does not need Node.js or npm. When running from source, missing `web/dist` still triggers the local frontend build path. + ### 4. Common commands ```text diff --git a/README.md b/README.md index 2a32173c..7a3d3637 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ neocode --workdir /path/to/your/project neocode web ``` -标签发布版会在缺少 `web/dist` 时自动使用发布包内的 `web/` 源码执行 `npm install` 和 `npm run build`。这要求用户机器已安装 Node.js 和 npm;如果你使用源码仓库运行,也保留相同的自动构建行为。 +标签发布版已经将 Web UI 的 `web/dist` 内嵌进 `neocode` 二进制,执行 `neocode web` 时不再要求用户机器安装 Node.js 或 npm。如果你在源码仓库里运行 `go run ./cmd/neocode web`,当本地缺少 `web/dist` 时仍会自动尝试构建前端。 ### 4. 常用命令 diff --git a/internal/cli/release_config_test.go b/internal/cli/release_config_test.go new file mode 100644 index 00000000..90bbb7f6 --- /dev/null +++ b/internal/cli/release_config_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +type goreleaserBuild struct { + ID string `yaml:"id"` + Tags []string `yaml:"tags"` +} + +type goreleaserConfig struct { + Builds []goreleaserBuild `yaml:"builds"` +} + +// repoRootForReleaseConfigTest 解析仓库根目录,供发布配置回归测试读取工作流与 GoReleaser 文件。 +func repoRootForReleaseConfigTest(t *testing.T) string { + t.Helper() + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..")) +} + +func TestGoReleaserEmbedsWebAssetsOnlyForNeoCode(t *testing.T) { + repoRoot := repoRootForReleaseConfigTest(t) + raw, err := os.ReadFile(filepath.Join(repoRoot, ".goreleaser.yaml")) + if err != nil { + t.Fatalf("read .goreleaser.yaml: %v", err) + } + + var cfg goreleaserConfig + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("unmarshal .goreleaser.yaml: %v", err) + } + + builds := make(map[string]goreleaserBuild, len(cfg.Builds)) + for _, build := range cfg.Builds { + builds[build.ID] = build + } + + neocodeBuild, ok := builds["neocode"] + if !ok { + t.Fatal("missing neocode build in .goreleaser.yaml") + } + if !slicesContains(neocodeBuild.Tags, "webembed") { + t.Fatalf("neocode build tags = %v, want webembed", neocodeBuild.Tags) + } + + gatewayBuild, ok := builds["neocode-gateway"] + if !ok { + t.Fatal("missing neocode-gateway build in .goreleaser.yaml") + } + if slicesContains(gatewayBuild.Tags, "webembed") { + t.Fatalf("neocode-gateway build tags = %v, want no webembed", gatewayBuild.Tags) + } +} + +func TestReleaseWorkflowBuildsWebDistBeforeGoReleaser(t *testing.T) { + repoRoot := repoRootForReleaseConfigTest(t) + raw, err := os.ReadFile(filepath.Join(repoRoot, ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("read release workflow: %v", err) + } + content := string(raw) + + for _, expected := range []string{ + "actions/setup-node@v4", + "npm ci", + "npm run build", + } { + if !strings.Contains(content, expected) { + t.Fatalf("release workflow missing %q", expected) + } + } +} + +func slicesContains(values []string, target string) bool { + for _, value := range values { + if strings.TrimSpace(value) == target { + return true + } + } + return false +} diff --git a/internal/cli/web_command.go b/internal/cli/web_command.go index 39fcee87..3722da13 100644 --- a/internal/cli/web_command.go +++ b/internal/cli/web_command.go @@ -25,6 +25,12 @@ var ( webCommandStartGatewayServer = startGatewayServer webCommandBuildFrontend = buildFrontend webCommandLookPath = exec.LookPath + webCommandEmbeddedAssets = func() (fs.FS, bool) { + if !webassets.IsAvailable() { + return nil, false + } + return webassets.FS, true + } ) type webCommandOptions struct { @@ -57,7 +63,7 @@ func newWebCommand() *cobra.Command { cmd.Flags().StringVar(&options.LogLevel, "log-level", "info", "gateway log level: debug|info|warn|error") cmd.Flags().StringVar(&options.StaticDir, "static-dir", "", "frontend static files directory override") cmd.Flags().BoolVar(&options.OpenBrowser, "open-browser", true, "open browser automatically") - cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip frontend build (error if dist/ missing)") + cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip local frontend build (still works with embedded assets)") cmd.Flags().StringVar(&options.TokenFile, "token-file", "", "gateway auth token file path") return cmd @@ -66,6 +72,7 @@ func newWebCommand() *cobra.Command { // runWebCommand 执行 web 子命令:解析前端目录 → 构建前端(可选) → 启动 Gateway → 打开浏览器。 func runWebCommand(ctx context.Context, options webCommandOptions) error { logger := log.New(os.Stderr, "neocode-web: ", log.LstdFlags) + embeddedAssets, embeddedAssetsAvailable := webCommandEmbeddedAssets() // 如果未指定 workdir,默认使用当前工作目录 if strings.TrimSpace(options.Workdir) == "" { @@ -76,47 +83,55 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error { // 1. 解析前端静态文件目录 staticDir, err := resolveWebStaticDir(options.StaticDir) - needBuild := err != nil - - // 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时) - if err == nil && options.StaticDir == "" { - webDir := findWebSourceDir() - if webDir != "" && isStaleFrontendBuild(webDir) { - logger.Println("frontend source is newer than build output, rebuilding...") - needBuild = true + switch { + case options.StaticDir != "": + if err != nil { + return fmt.Errorf("invalid --static-dir: %w", err) } - } - - if needBuild { + case err != nil && embeddedAssetsAvailable: + logger.Println("frontend dist not found, falling back to embedded assets") + staticDir = "" + case err != nil: if options.SkipBuild { - return fmt.Errorf("frontend needs rebuild and --skip-build is set") + return fmt.Errorf("frontend assets missing and --skip-build is set") } webDir := findWebSourceDir() if webDir == "" { - if err != nil { - return fmt.Errorf( - "frontend assets unavailable: %w; release packages must include the web/ source directory, or source builds must run from the project root or use --static-dir", - err, - ) - } return fmt.Errorf( - "web source directory not found; release packages must include web/, or source builds must run from project root or set --static-dir", + "frontend assets unavailable: %w; source builds must run from the project root, provide --static-dir, or use a release binary with embedded web assets", + err, ) } if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil { - return fmt.Errorf("frontend build failed on this machine after detecting bundled web source: %w", buildErr) + return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr) } - // 构建后重新解析 staticDir, err = resolveWebStaticDir(options.StaticDir) if err != nil { return fmt.Errorf("frontend dist not found after build: %w", err) } + default: + // 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时) + webDir := findWebSourceDir() + if webDir != "" && isStaleFrontendBuild(webDir) { + logger.Println("frontend source is newer than build output, rebuilding...") + if options.SkipBuild { + return fmt.Errorf("frontend needs rebuild and --skip-build is set") + } + if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil { + return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr) + } + staticDir, err = resolveWebStaticDir(options.StaticDir) + if err != nil { + return fmt.Errorf("frontend dist not found after build: %w", err) + } + } } + // 2. 确定静态文件来源:外部目录优先,找不到时回退到嵌入资源 var staticFileFS fs.FS if staticDir == "" { - if webassets.IsAvailable() { - staticFileFS = webassets.FS + if embeddedAssetsAvailable { + staticFileFS = embeddedAssets logger.Println("serving web UI from embedded assets") } else { logger.Println("warning: no web UI assets found (external dist missing and embedded assets not compiled)") diff --git a/internal/cli/web_command_test.go b/internal/cli/web_command_test.go index 0f2cde78..0c0d2335 100644 --- a/internal/cli/web_command_test.go +++ b/internal/cli/web_command_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "testing/fstest" ) // writeWebCommandTestFile 写入 web 命令测试所需的最小文件内容,避免各测试重复拼装目录。 @@ -76,6 +77,18 @@ func stubWebCommandHooks( }) } +// stubWebCommandEmbeddedAssets 替换内嵌前端资源探测逻辑,便于覆盖发布版回退分支。 +func stubWebCommandEmbeddedAssets(t *testing.T, assets fs.FS, available bool) { + t.Helper() + original := webCommandEmbeddedAssets + webCommandEmbeddedAssets = func() (fs.FS, bool) { + return assets, available + } + t.Cleanup(func() { + webCommandEmbeddedAssets = original + }) +} + func TestFindWebSourceDirUsesCurrentWorkdir(t *testing.T) { tempDir := t.TempDir() chdirForWebCommandTest(t, tempDir) @@ -186,3 +199,90 @@ func TestRunWebCommandBuildsFrontendWhenDistMissing(t *testing.T) { t.Fatalf("startGatewayServer staticDir = %q, want %q", capturedStaticDir, wantStaticDir) } } + +func TestRunWebCommandUsesEmbeddedAssetsWhenDistMissing(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}") + + buildCalled := false + var capturedStaticDir string + var capturedStaticFS fs.FS + sentinelErr := errors.New("stop after start") + stubWebCommandEmbeddedAssets(t, fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("")}, + }, true) + stubWebCommandHooks( + t, + func(_ context.Context, _ gatewayCommandOptions, staticDir string, staticFS fs.FS, _ func(string)) error { + capturedStaticDir = staticDir + capturedStaticFS = staticFS + return sentinelErr + }, + func(_ string, _ *log.Logger) error { + buildCalled = true + return nil + }, + nil, + ) + + err := runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + OpenBrowser: false, + Workdir: tempDir, + }) + if !errors.Is(err, sentinelErr) { + t.Fatalf("runWebCommand() error = %v, want sentinel error %v", err, sentinelErr) + } + if buildCalled { + t.Fatal("runWebCommand() unexpectedly invoked frontend build when embedded assets were available") + } + if capturedStaticDir != "" { + t.Fatalf("startGatewayServer staticDir = %q, want empty string", capturedStaticDir) + } + if capturedStaticFS == nil { + t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS") + } +} + +func TestRunWebCommandSkipBuildStillUsesEmbeddedAssets(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + + buildCalled := false + var capturedStaticFS fs.FS + sentinelErr := errors.New("stop after start") + stubWebCommandEmbeddedAssets(t, fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("")}, + }, true) + stubWebCommandHooks( + t, + func(_ context.Context, _ gatewayCommandOptions, _ string, staticFS fs.FS, _ func(string)) error { + capturedStaticFS = staticFS + return sentinelErr + }, + func(_ string, _ *log.Logger) error { + buildCalled = true + return nil + }, + nil, + ) + + err := runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + OpenBrowser: false, + SkipBuild: true, + Workdir: tempDir, + }) + if !errors.Is(err, sentinelErr) { + t.Fatalf("runWebCommand() error = %v, want sentinel error %v", err, sentinelErr) + } + if buildCalled { + t.Fatal("runWebCommand() unexpectedly invoked frontend build when --skip-build used with embedded assets") + } + if capturedStaticFS == nil { + t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS") + } +} diff --git a/www/en/guide/install.md b/www/en/guide/install.md index e0fec326..68edbcde 100644 --- a/www/en/guide/install.md +++ b/www/en/guide/install.md @@ -63,6 +63,14 @@ neocode --workdir /path/to/your/project Type natural language in the input box to chat. Type `/` to see local control commands. +NeoCode also provides a browser-based Web UI: + +```bash +neocode web +``` + +Tagged release builds start the Web UI from assets already embedded in the `neocode` binary, so the target machine does not need Node.js or npm. When running from source, a missing `web/dist` still triggers a local frontend build. + ## 5. First conversation Try: diff --git a/www/guide/install.md b/www/guide/install.md index 6c4f1563..252b516e 100644 --- a/www/guide/install.md +++ b/www/guide/install.md @@ -73,7 +73,7 @@ neocode --workdir /path/to/your/project neocode web ``` -标签发布版执行 `neocode web` 时,如果本地还没有 `web/dist`,会自动使用发布包内的 `web/` 源码执行 `npm install` 和 `npm run build`,然后启动 Web UI。该流程要求当前机器已安装 Node.js 和 npm。 +标签发布版执行 `neocode web` 时,会直接使用二进制内嵌的 Web UI 资源启动,不要求当前机器安装 Node.js 或 npm。 ## 5. 第一次对话 @@ -118,7 +118,7 @@ go build ./... go run ./cmd/neocode ``` -如果你希望从源码仓库直接验证 Web UI,也可以运行 `go run ./cmd/neocode web`。当 `web/dist` 缺失时,命令会自动尝试构建前端;若构建机没有 Node.js/npm,会直接报出依赖缺失提示。 +如果你希望从源码仓库直接验证 Web UI,也可以运行 `go run ./cmd/neocode web`。当 `web/dist` 缺失时,命令会自动尝试构建前端;若构建机没有 Node.js/npm,会直接报出依赖缺失提示。也就是说,只有源码运行场景仍依赖本地前端构建链路。 如果你只想稳定使用,优先使用一键安装方式。源码构建更适合阅读代码、调试功能或参与开发。 diff --git a/www/guide/web-ui.md b/www/guide/web-ui.md index d4d83ec1..98bfc86d 100644 --- a/www/guide/web-ui.md +++ b/www/guide/web-ui.md @@ -14,7 +14,7 @@ neocode web ``` 启动后浏览器会自动打开 `http://127.0.0.1:8080`。如果端口被占用,会自动尝试 8081 ~ 8090。 -如果当前目录或发布包内存在 `web/` 源码但还没有 `web/dist`,命令会自动执行 `npm install` 和 `npm run build`。标签发布版使用该能力时,用户机器必须预先安装 Node.js 和 npm。 +标签发布版会直接使用二进制内嵌的 Web UI 资源启动,不要求用户机器安装 Node.js 或 npm。只有在源码仓库运行、且本地缺少 `web/dist` 时,命令才会自动执行 `npm install` 和 `npm run build`。 ### 常用参数 @@ -22,7 +22,7 @@ neocode web |------|--------|------| | `--http-listen` | `127.0.0.1:8080` | 监听地址(仅允许回环地址) | | `--open-browser` | `true` | 启动后自动打开浏览器 | -| `--skip-build` | `false` | 跳过前端构建(dist/ 缺失时会报错;仅在你已准备好预构建资源时使用) | +| `--skip-build` | `false` | 跳过本地前端构建(若二进制已内嵌资源仍可启动;主要用于源码模式或自备 dist 的场景) | | `--static-dir` | — | 指定前端静态文件目录 | | `--log-level` | `info` | 日志级别:debug / info / warn / error | | `--token-file` | — | 自定义认证 token 文件路径 | @@ -57,7 +57,7 @@ Web UI 支持两种运行模式,根据启动方式自动选择: ### 浏览器模式 通过 `neocode web` 或直接在浏览器中访问 Gateway 地址时使用。首次连接需要输入 Gateway URL 和 token,配置保存在 sessionStorage 中。 -如果你使用标签发布版,首次运行 `neocode web` 可能先看到前端依赖安装与构建日志;构建完成后会继续启动 Web UI。若机器缺少 Node.js/npm,命令会直接提示安装依赖。 +如果你使用标签发布版,`neocode web` 会直接启动内嵌的 Web UI。若你使用源码仓库运行并且本地没有 `web/dist`,命令才会先执行前端依赖安装与构建;此时如果机器缺少 Node.js/npm,会直接提示安装依赖。 ## 核心功能 From 3cec0e03f794530f42744357e0b9167ea695293b Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 9 May 2026 11:51:17 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=E4=BF=AE=E6=8A=A4tui=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gateway_runtime_bridge.go | 9 ++++-- internal/cli/gateway_runtime_bridge_test.go | 35 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index 37050465..b75c122d 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -2109,10 +2109,13 @@ func (b *gatewayRuntimePortBridge) resolveEffectiveProviderModel( } session, err := b.loadStoredSession(ctx, sessionID) if err != nil { - return "", "", err + if !isRuntimeNotFoundError(err) { + return "", "", err + } + } else { + sessionProviderID = strings.TrimSpace(session.Provider) + sessionModelID = strings.TrimSpace(session.Model) } - sessionProviderID = strings.TrimSpace(session.Provider) - sessionModelID = strings.TrimSpace(session.Model) } cfg := b.currentConfig() diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index ad11ba13..08484bd2 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -2630,6 +2630,41 @@ func TestGatewayRuntimePortBridgeListModelsUsesSessionProvider(t *testing.T) { } } +func TestGatewayRuntimePortBridgeListModelsSessionNotFoundFallsBackToGlobal(t *testing.T) { + store := &bridgeSessionStoreWithLoader{ + bridgeSessionStoreStub: bridgeSessionStoreStub{}, + loadErr: agentsession.ErrSessionNotFound, + } + cfgMgr := &configManagerStub{ + cfg: config.Config{ + SelectedProvider: "gemini", + CurrentModel: "gemini-2.5-pro", + }, + } + ps := &providerSelectionStub{ + listOptions: []configstate.ProviderOption{ + {ID: "openai", Models: []providertypes.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}}, + {ID: "gemini", Models: []providertypes.ModelDescriptor{{ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"}}}, + }, + } + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, store, cfgMgr, ps) + defer bridge.Close() + + models, err := bridge.ListModels(context.Background(), gateway.ListModelsInput{ + SubjectID: testBridgeSubjectID, + SessionID: "session-startup-probe-1", + }) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models) != 1 { + t.Fatalf("models len = %d, want 1", len(models)) + } + if models[0].Provider != "gemini" || models[0].ID != "gemini-2.5-pro" { + t.Fatalf("models = %+v, want gemini/gemini-2.5-pro only", models) + } +} + func TestGatewayRuntimePortBridgeGetSessionModelFallsBackToEffectiveSelection(t *testing.T) { store := &bridgeSessionStoreWithLoader{ bridgeSessionStoreStub: bridgeSessionStoreStub{},