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/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 58750d2c..08484bd2 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")
@@ -2583,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{},
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/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/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/FileChangePanel.test.tsx b/web/src/components/panels/FileChangePanel.test.tsx
index 514d70c8..097e0957 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,22 +236,102 @@ 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', () => {
+ 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 () => {
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(() => {
@@ -243,6 +344,176 @@ 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: {
+ 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: [
diff --git a/web/src/components/panels/FileChangePanel.tsx b/web/src/components/panels/FileChangePanel.tsx
index fde7bd77..b8947a48 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'
@@ -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') {
@@ -160,18 +167,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 (
-
+
@@ -461,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]
@@ -498,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
- return (
-
{
+ tabRefs.current[tab.id] = node
+ }}
+ id={`preview-tab-${tab.id}`}
+ data-testid={`preview-tab-${tab.id}`}
+ data-context-active={contextActive ? 'true' : 'false'}
+ role="tab"
+ type="button"
+ aria-selected={active}
+ aria-controls={`preview-panel-${tab.id}`}
+ tabIndex={active ? 0 : -1}
+ title={title}
+ onClick={() => onActivate(tab.id)}
+ onKeyDown={(event) => handleKeyDown(event, index)}
+ style={{
+ ...styles.switcherButton,
+ ...(active ? styles.switcherButtonActive : {}),
+ ...(contextActive ? styles.switcherButtonContext : {}),
+ }}
+ >
+
{tab.title}
+
+ )
+ }
+
+ return (
+
+
+ {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')
+ })}
+
+ )}
)
}
@@ -686,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: 'center',
+ minWidth: 0,
+ },
+ switcherRail: {
display: 'flex',
- alignItems: 'stretch',
+ 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',
@@ -786,7 +935,7 @@ const styles: Record = {
viewContainer: {
display: 'flex',
flexDirection: 'column',
- height: '100%',
+ flex: 1,
minHeight: 0,
overflow: 'hidden',
},
@@ -836,6 +985,8 @@ const styles: Record = {
minHeight: 0,
overflow: 'auto',
padding: 12,
+ },
+ contentStack: {
display: 'flex',
flexDirection: 'column',
gap: 10,
@@ -853,6 +1004,7 @@ const styles: Record = {
borderRadius: 10,
overflow: 'hidden',
background: 'var(--bg-primary)',
+ flexShrink: 0,
},
changeHeader: {
width: '100%',
@@ -955,7 +1107,8 @@ const styles: Record = {
},
hunkScroller: {
overflowX: 'auto',
- overflowY: 'visible',
+ overflowY: 'hidden',
+ maxWidth: '100%',
},
diffLine: {
display: 'grid',
@@ -1038,6 +1191,7 @@ const styles: Record = {
color: 'inherit',
cursor: 'pointer',
textAlign: 'left',
+ flexShrink: 0,
},
gitDiffBadge: {
display: 'inline-flex',
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`
}
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,会直接提示安装依赖。
## 核心功能