diff --git a/desktop/internal/core/build.go b/desktop/internal/core/build.go index bdf866584..05d34db78 100644 --- a/desktop/internal/core/build.go +++ b/desktop/internal/core/build.go @@ -269,7 +269,8 @@ type buildOutcome struct { Version *BuildToolVersion `json:"version"` // Dependencies is the answer of build.decodeDependencies. Dependencies *BuildDependencyTree `json:"dependencies"` - // Storage and Edit answer build.resolveStoredOptions and build.editStoredOption. + // Storage and Edit answer build.resolveStoredOptions and build.editStoredOption; build.editIgnoredPaths + // decodes its own edit. Storage *BuildStoredOptions `json:"storage"` Edit *BuildOptionEdit `json:"edit"` // Discovery answers build.decodeTestDiscovery. @@ -312,7 +313,8 @@ func (c *Client) DetectBuildSystemsWithOptions(ctx context.Context, workspaceRoo InstanceOptions map[string]map[string]map[string]json.RawMessage `json:"instanceOptions,omitempty"` InstanceDefaults map[string]map[string]json.RawMessage `json:"instanceDefaults,omitempty"` EmptySelections map[string]map[string][]string `json:"emptySelections,omitempty"` - }{workspaceRoot, plugins, options.Options, options.InstanceOptions, options.InstanceDefaults, options.EmptySelections}, &result); err != nil { + IgnoredPaths []string `json:"ignoredPaths"` + }{workspaceRoot, plugins, options.Options, options.InstanceOptions, options.InstanceDefaults, options.EmptySelections, options.IgnoredPaths}, &result); err != nil { return BuildDetection{}, err } if err := result.failure(command); err != nil { diff --git a/desktop/internal/core/build_options.go b/desktop/internal/core/build_options.go index d3a9e31dd..9d988cff5 100644 --- a/desktop/internal/core/build_options.go +++ b/desktop/internal/core/build_options.go @@ -7,20 +7,56 @@ import ( // BuildDetectOptions are the stored option inputs of build.detect: values by build system and option ID, // instance values by root, instance defaults and the explicit empty selections. Core merges them into each -// instance's options and names the rejected ones in droppedOptions. +// instance's options and names the rejected ones in droppedOptions. IgnoredPaths are the directories the +// scan does not enter; nil is Core's default, every directory whose name starts with a dot, and an empty +// list ignores nothing beyond the shared scan policy. type BuildDetectOptions struct { Options map[string]map[string]json.RawMessage `json:"options,omitempty"` InstanceOptions map[string]map[string]map[string]json.RawMessage `json:"instanceOptions,omitempty"` InstanceDefaults map[string]map[string]json.RawMessage `json:"instanceDefaults,omitempty"` EmptySelections map[string]map[string][]string `json:"emptySelections,omitempty"` + IgnoredPaths []string `json:"ignoredPaths"` } // BuildStoredOptions is the checked merge of the team document and the platform-local document that -// build.resolveStoredOptions returns. Document is the merged document, Local the validated local one. +// build.resolveStoredOptions returns. Document is the merged document, Local the validated local one, and +// IgnoredPaths the list build detection uses with the layer it came from. type BuildStoredOptions struct { Document json.RawMessage `json:"document"` Local json.RawMessage `json:"local"` EmptySelections map[string]map[string][]string `json:"emptySelections"` + IgnoredPaths BuildIgnoredPaths `json:"ignoredPaths"` +} + +// The layers an ignored-path list comes from: the local document, the team document, or neither, when +// Core's default applies. +const ( + BuildIgnoredPathsLocal = "local" + BuildIgnoredPathsTeam = "team" + BuildIgnoredPathsDefault = "default" +) + +// BuildIgnoredPaths are the .gitignore patterns, relative to the workspace root, of the directories build +// detection does not enter, and the layer that set them: a local list replaces the team's whole. +type BuildIgnoredPaths struct { + Patterns []string `json:"patterns"` + Source string `json:"source"` +} + +// BuildIgnoredPathsEditRequest replaces the local ignored-path list with Patterns, an empty list being the +// explicit choice to ignore nothing, or removes it when Inherit is set, so the team list or Core's default +// applies again. +type BuildIgnoredPathsEditRequest struct { + Document json.RawMessage + Local json.RawMessage + Patterns []string + Inherit bool +} + +// BuildIgnoredPathsEdit is the local document after an ignored-path edit and the list that applies with it. +type BuildIgnoredPathsEdit struct { + Local json.RawMessage `json:"local"` + IgnoredPaths BuildIgnoredPaths `json:"ignoredPaths"` } // BuildOptionEditRequest changes one declared local preference of an instance. A nil Value removes the local @@ -92,3 +128,35 @@ func (c *Client) EditStoredBuildOption(ctx context.Context, manifest json.RawMes } return *result.Edit, nil } + +// EditBuildIgnoredPaths applies an ignored-path edit to the local document. It reads and writes no file: the +// team document is only read, and the host writes edit.Local itself, atomically, and reads it back. Core +// refuses a list it cannot use with plugin.build.invalidIgnoredPath at ignoredPaths[], the first +// pattern not accepted, or at ignoredPaths when the patterns cannot be used together. +func (c *Client) EditBuildIgnoredPaths(ctx context.Context, request BuildIgnoredPathsEditRequest) (BuildIgnoredPathsEdit, error) { + const command = "build.editIgnoredPaths" + // patterns is null to remove the local list, and a list, empty or not, to replace it. + var patterns any + if !request.Inherit { + patterns = append([]string{}, request.Patterns...) + } + // Both commands answer under edit; this one with the ignored paths in place of the options. + var result struct { + buildOutcome + Edit *BuildIgnoredPathsEdit `json:"edit"` + } + if err := c.Execute(ctx, command, struct { + Document json.RawMessage `json:"document"` + Local json.RawMessage `json:"local"` + Patterns any `json:"patterns"` + }{request.Document, request.Local, patterns}, &result); err != nil { + return BuildIgnoredPathsEdit{}, err + } + if err := result.failure(command); err != nil { + return BuildIgnoredPathsEdit{}, err + } + if result.Edit == nil { + return BuildIgnoredPathsEdit{}, newParseError(command, "build.editIgnoredPaths returned no edit", "") + } + return *result.Edit, nil +} diff --git a/desktop/internal/core/build_options_test.go b/desktop/internal/core/build_options_test.go new file mode 100644 index 000000000..e744c6604 --- /dev/null +++ b/desktop/internal/core/build_options_test.go @@ -0,0 +1,113 @@ +package core + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/contractfixture" +) + +// emptyBuildLocal is the local Build document of a project that stores nothing. +var emptyBuildLocal = json.RawMessage(`{"version":1,"document":{"version":1,"systems":{}}}`) + +// The ignored paths come from the local document, else the team document, else Core's default of every +// dot-named directory. An edit replaces the local list, an empty list included, or removes it so the +// team's applies again; Core names the first pattern it does not accept. +func TestBuildIgnoredPathsResolveFromTheLayersAndEditTheLocalList(t *testing.T) { + client, ctx := NewClient(), testContext(t) + noTeam := json.RawMessage(`{"version":1,"systems":{}}`) + team := json.RawMessage(`{"version":1,"systems":{},"ignoredPaths":["vendor/"]}`) + resolved := func(document, local json.RawMessage) BuildIgnoredPaths { + t.Helper() + storage, err := client.ResolveStoredBuildOptions(ctx, document, local) + if err != nil { + t.Fatal(err) + } + return storage.IgnoredPaths + } + if got := resolved(noTeam, emptyBuildLocal); !slices.Equal(got.Patterns, []string{".*"}) || got.Source != BuildIgnoredPathsDefault { + t.Fatalf("without a stored list the ignored paths are %+v, want Core's default", got) + } + if got := resolved(team, emptyBuildLocal); !slices.Equal(got.Patterns, []string{"vendor/"}) || got.Source != BuildIgnoredPathsTeam { + t.Fatalf("with the team's list the ignored paths are %+v", got) + } + + edit, err := client.EditBuildIgnoredPaths(ctx, BuildIgnoredPathsEditRequest{Document: team, Local: emptyBuildLocal, Patterns: []string{".*", "!.config"}}) + if err != nil { + t.Fatal(err) + } + if got := edit.IgnoredPaths; !slices.Equal(got.Patterns, []string{".*", "!.config"}) || got.Source != BuildIgnoredPathsLocal { + t.Fatalf("after the edit the ignored paths are %+v", got) + } + if got := resolved(team, edit.Local); !slices.Equal(got.Patterns, []string{".*", "!.config"}) || got.Source != BuildIgnoredPathsLocal { + t.Fatalf("the edited local document resolves to %+v", got) + } + + // No patterns is the choice to ignore nothing, which is kept, not the default. + none, err := client.EditBuildIgnoredPaths(ctx, BuildIgnoredPathsEditRequest{Document: team, Local: edit.Local}) + if err != nil { + t.Fatal(err) + } + if got := resolved(team, none.Local); got.Patterns == nil || len(got.Patterns) != 0 || got.Source != BuildIgnoredPathsLocal { + t.Fatalf("an empty local list resolves to %+v, want an empty list from the local document", got) + } + + inherited, err := client.EditBuildIgnoredPaths(ctx, BuildIgnoredPathsEditRequest{Document: team, Local: none.Local, Inherit: true}) + if err != nil { + t.Fatal(err) + } + if got := inherited.IgnoredPaths; !slices.Equal(got.Patterns, []string{"vendor/"}) || got.Source != BuildIgnoredPathsTeam { + t.Fatalf("removing the local list leaves %+v, want the team's", got) + } + + _, err = client.EditBuildIgnoredPaths(ctx, BuildIgnoredPathsEditRequest{Document: team, Local: emptyBuildLocal, Patterns: []string{".*", "# note"}}) + var rejection *MarketplaceRejection + if !errors.As(err, &rejection) || rejection.Code != "plugin.build.invalidIgnoredPath" || rejection.Field != "ignoredPaths[1]" { + t.Fatalf("a comment line answered %v, want plugin.build.invalidIgnoredPath at ignoredPaths[1]", err) + } +} + +// Detection skips every dot-named directory unless it is given the list to use; an empty list ignores +// nothing beyond the shared scan policy. +func TestBuildDetectSkipsTheIgnoredPaths(t *testing.T) { + client, ctx := NewClient(), testContext(t) + root := t.TempDir() + for _, dir := range []string{".", ".claude/copy", "tools"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, dir, "Cargo.toml"), []byte("[package]\nname = \"app\"\nversion = \"0.1.0\"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + manifest, err := os.ReadFile(contractfixture.Path("fixtures/build-systems/build-static-file.plugin.json")) + if err != nil { + t.Fatal(err) + } + roots := func(ignored []string) []string { + t.Helper() + detection, err := client.DetectBuildSystemsWithOptions(ctx, root, []json.RawMessage{manifest}, BuildDetectOptions{IgnoredPaths: ignored}) + if err != nil { + t.Fatal(err) + } + var roots []string + for _, instance := range detection.Instances { + roots = append(roots, instance.Root) + } + slices.Sort(roots) + return roots + } + if got := roots(nil); !slices.Equal(got, []string{".", "tools"}) { + t.Fatalf("by default detection finds %q, want the dot-named directory skipped", got) + } + if got := roots([]string{}); !slices.Equal(got, []string{".", ".claude/copy", "tools"}) { + t.Fatalf("ignoring nothing detection finds %q", got) + } + if got := roots([]string{"/tools"}); !slices.Equal(got, []string{".", ".claude/copy"}) { + t.Fatalf("ignoring /tools detection finds %q", got) + } +} diff --git a/desktop/internal/i18n/shared.generated.go b/desktop/internal/i18n/shared.generated.go index 1d22e9013..0ef93ed14 100644 --- a/desktop/internal/i18n/shared.generated.go +++ b/desktop/internal/i18n/shared.generated.go @@ -76,10 +76,18 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.build.commandUnsupported": "This build system answered with a command plan; provider-backed tasks are required here.", "desktop.build.debugTarget": "Debug", "desktop.build.dependenciesHelp": "The build tool reads the dependency tree only when this is first opened, which can take a while as the tool runs. The tree is then kept until the build systems are detected again or Reload is chosen on the build system or on Dependencies.", + "desktop.build.detection": "Detection", "desktop.build.emptyOutput": "Run a build task to see output.", "desktop.build.executableMissing": "No usable build executable was found for this build system.", "desktop.build.exited": "Build exited with code {code}.", "desktop.build.filter": "Filter build systems, tasks and targets", + "desktop.build.ignoredPaths": "Ignored Paths", + "desktop.build.ignoredPathsDefault": "Using the default list; changes are saved on this machine only.", + "desktop.build.ignoredPathsHelp": "One rule per line, written as in .gitignore and relative to the project root; rules match folders only. Later rules win, and a rule starting with ! brings a folder back into detection, even one Git ignores. By default every folder whose name starts with a dot, such as .claude/worktrees, is skipped; dependency caches and build outputs are always skipped. At most 256 rules.", + "desktop.build.ignoredPathsLocal": "Using the list saved on this machine.", + "desktop.build.ignoredPathsTeam": "Using the list in the team file {file}; changes are saved on this machine only.", + "desktop.build.invalidIgnoredPath": "Line {line} is not a usable ignore rule. Rules follow .gitignore syntax, have no leading or trailing spaces, do not start with # or consist of a lone !, are at most 1024 bytes long, and number at most 256.", + "desktop.build.invalidIgnoredPaths": "These ignore rules cannot be used together. Check how they are written.", "desktop.build.loadingModel": "Loading build model…", "desktop.build.modelFailed": "The build model could not be loaded: {error}", "desktop.build.noBuildSystems": "No provider-backed build system was detected.", @@ -2549,10 +2557,18 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.build.commandUnsupported": "此构建系统返回了命令计划;当前接入需要提供者驱动的任务。", "desktop.build.debugTarget": "调试", "desktop.build.dependenciesHelp": "第一次展开时才让构建工具读取依赖树,构建工具运行期间可能要等一会儿。读到的依赖会一直保留,直到重新检测构建系统,或在构建系统或“依赖”上选“重新加载”。", + "desktop.build.detection": "检测", "desktop.build.emptyOutput": "运行构建任务以查看输出。", "desktop.build.executableMissing": "未找到此构建系统可用的构建程序。", "desktop.build.exited": "构建以代码 {code} 退出。", "desktop.build.filter": "筛选构建系统、任务与目标", + "desktop.build.ignoredPaths": "忽略路径", + "desktop.build.ignoredPathsDefault": "当前使用默认设置;修改后只保存在本机。", + "desktop.build.ignoredPathsHelp": "一行一条规则,写法同 .gitignore,相对项目根目录,只匹配文件夹。后面的规则优先,以 ! 开头的规则让文件夹重新参与检测,包括被 Git 忽略的文件夹。默认忽略所有以 . 开头的文件夹,例如 .claude/worktrees;依赖缓存和构建输出始终跳过。最多 256 条。", + "desktop.build.ignoredPathsLocal": "当前使用本机保存的设置。", + "desktop.build.ignoredPathsTeam": "当前使用团队文件 {file} 中的设置;修改后只保存在本机。", + "desktop.build.invalidIgnoredPath": "第 {line} 行不是可用的忽略规则。规则要符合 .gitignore 写法,首尾不能有空格,不能以 # 开头或只写 !,每条最长 1024 字节,最多 256 条。", + "desktop.build.invalidIgnoredPaths": "这些忽略规则不能一起使用,请检查写法。", "desktop.build.loadingModel": "正在加载构建模型…", "desktop.build.modelFailed": "构建模型加载失败:{error}", "desktop.build.noBuildSystems": "没有检测到由提供者驱动的构建系统。", @@ -2561,7 +2577,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.build.noOptions": "此构建系统没有声明可配置的选项。", "desktop.build.noProjects": "构建模型中没有项目", "desktop.build.noTasks": "没有构建任务", - "desktop.build.options": "构建选项", + "desktop.build.options": "选项", "desktop.build.optionsTitle": "构建选项", "desktop.build.output": "构建输出", "desktop.build.pluginsUnavailable": "此窗口无法使用构建插件。", diff --git a/desktop/internal/workbench/app.go b/desktop/internal/workbench/app.go index b5dcabbf9..908ab98fb 100644 --- a/desktop/internal/workbench/app.go +++ b/desktop/internal/workbench/app.go @@ -63,6 +63,7 @@ type Kernel interface { DetectBuildSystemsWithOptions(ctx context.Context, workspaceRoot string, manifests []json.RawMessage, options core.BuildDetectOptions) (core.BuildDetection, error) ResolveStoredBuildOptions(ctx context.Context, document, local json.RawMessage) (core.BuildStoredOptions, error) EditStoredBuildOption(ctx context.Context, manifest json.RawMessage, buildSystemID string, request core.BuildOptionEditRequest) (core.BuildOptionEdit, error) + EditBuildIgnoredPaths(ctx context.Context, request core.BuildIgnoredPathsEditRequest) (core.BuildIgnoredPathsEdit, error) IDEHostOpen(ctx context.Context, request core.IDEHostOpenRequest) (core.IDEHostOpenResult, error) IDEHostPoll(ctx context.Context, hostID string) (core.IDEHostPollResult, error) IDEHostClaim(ctx context.Context, hostID, requestID string) (core.IDEHostClaimResult, error) diff --git a/desktop/internal/workbench/build.go b/desktop/internal/workbench/build.go index 15f004205..278a7c7fa 100644 --- a/desktop/internal/workbench/build.go +++ b/desktop/internal/workbench/build.go @@ -41,8 +41,9 @@ type buildState struct { loaded bool loading bool failed string - // options is the Build options editor of the selected instance (build_options.go). - options buildOptionsEditor + // options is the Build options dialog (build_options.go) and optionsSessions counts its openings. + options buildOptionsEditor + optionsSessions int // reloadPending says a stored option changed while a detection was running, so the instances are // detected again when that detection ends. reloadPending bool diff --git a/desktop/internal/workbench/build_ignored_paths_test.go b/desktop/internal/workbench/build_ignored_paths_test.go new file mode 100644 index 000000000..b65cbae57 --- /dev/null +++ b/desktop/internal/workbench/build_ignored_paths_test.go @@ -0,0 +1,249 @@ +package workbench + +import ( + "context" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/core" + "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" + "github.com/1lck/Lithe-IDEA/desktop/internal/pluginstate" + "github.com/1lck/Lithe-IDEA/desktop/internal/store" +) + +// ignoredPathsManifest is a declarative build system found wherever a fixture.build file is, with one +// option, so its instances open the Build options with an instance section. +const ignoredPathsManifest = `{ + "schemaVersion": 0, + "id": "dev.lithe.test.ignored-paths", + "displayName": "Ignored Paths Fixture", + "version": "1.0.0", + "pluginAPIVersion": 0, + "requires": {"buildSystems": 0}, + "vendor": {"id": "dev.lithe.test", "displayName": "Test", "signatureRequirement": "publisherPackage"}, + "permissions": ["process.build"], + "activationEvents": ["onBuildSystem:fixture-ignored"], + "languages": [], + "buildSystems": [{ + "id": "fixture-ignored", + "displayName": "Fixture Build", + "detection": {"markers": ["fixture.build"]}, + "invocation": {"executables": [{"kind": "command", "command": "fixture-build"}]}, + "options": [{"id": "release", "displayName": "Release Profile", "kind": "boolean", "arguments": ["--release"]}], + "tasks": [{"id": "build", "displayName": "Build", "group": "build", "scopes": ["project"], "arguments": ["build"]}] + }] +}` + +// openIgnoredPathsProject installs the fixture build system, opens a project with a fixture.build file +// in its root, in .claude/copy and in tools, writes team as the team Build options document when it is not +// empty, and opens the Build tool window once detection has finished. +func openIgnoredPathsProject(t *testing.T, team string) (*harness, *project) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, settingsFile), []byte(`{"theme":"dark"}`), 0o600); err != nil { + t.Fatal(err) + } + preferences := store.At(dir) + pluginRoot := t.TempDir() + installed := filepath.Join(pluginRoot, "dev.lithe.test.ignored-paths", "1.0.0") + if err := os.MkdirAll(installed, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "plugin.json"), []byte(ignoredPathsManifest), 0o600); err != nil { + t.Fatal(err) + } + if err := pluginstate.New(preferences).SetEnabled("dev.lithe.test.ignored-paths", true); err != nil { + t.Fatal(err) + } + root := t.TempDir() + for _, folder := range []string{".", ".claude/copy", "tools"} { + if err := os.MkdirAll(filepath.Join(root, folder), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, folder, "fixture.build"), nil, 0o600); err != nil { + t.Fatal(err) + } + } + if team != "" { + if err := os.MkdirAll(filepath.Join(root, ".lithe", "build"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(buildTeamOptionsFile)), []byte(team), 0o600); err != nil { + t.Fatal(err) + } + } + h := newHarnessWithOptions(t, Options{ + Kernel: &testKernel{Client: core.NewClient()}, + PluginKernel: &testKernel{Client: core.NewClient()}, + PluginRoot: pluginRoot, + Store: preferences, + Languages: []string{"en-US"}, + }) + h.open(root) + p := h.app.project + if p == nil { + t.Fatal("the project did not open") + } + h.app.reduceChrome(core.ToggleChrome(itemBuild)) + h.awaitBuildDetection(p) + return h, p +} + +// settleUntil settles the window until condition holds, a step at a time: each settle waits for the +// background work queued so far and paints its results, so a chain of steps ends within a few. +func (h *harness) settleUntil(what string, condition func() bool) { + h.t.Helper() + for range 12 { + h.settle() + if condition() { + return + } + } + h.t.Fatalf("%s did not happen", what) +} + +// awaitBuildDetection waits until the Build tool window has detected the instances of p. +func (h *harness) awaitBuildDetection(p *project) { + h.t.Helper() + h.settleUntil("build detection", func() bool { + return p.build.loaded && !p.build.loading && !p.build.reloadPending + }) + if p.build.failed != "" { + h.t.Fatalf("build detection failed: %s", p.build.failed) + } +} + +// buildRoots are the roots of the detected instances, sorted. +func buildRoots(p *project) []string { + var roots []string + for _, instance := range p.build.instances { + roots = append(roots, instance.Root) + } + slices.Sort(roots) + return roots +} + +// storedIgnoredPaths is the ignored-path list Core picks from what the project stores now. +func (h *harness) storedIgnoredPaths(p *project) core.BuildIgnoredPaths { + h.t.Helper() + ctx := context.Background() + docs, err := h.app.readBuildOptionDocuments(ctx, p.root) + if err != nil { + h.t.Fatal(err) + } + storage, err := core.NewClient().ResolveStoredBuildOptions(ctx, docs.team, docs.local) + if err != nil { + h.t.Fatal(err) + } + return storage.IgnoredPaths +} + +// awaitIgnoredPathsLoaded waits until the open Build options dialog shows the ignored paths Core picked. +func (h *harness) awaitIgnoredPathsLoaded(p *project) { + h.t.Helper() + h.settleUntil("the ignored paths", func() bool { + e := &p.build.options + return e.open && e.ignored.loaded && !e.ignored.restoring + }) +} + +// typeIgnoredPaths replaces the ignored-path field of the open dialog with text, typed into the field. +func (h *harness) typeIgnoredPaths(p *project, text string) { + h.t.Helper() + h.typeInto(h.shared("desktop.build.ignoredPaths"), func() { + h.app.post(func(*App) { p.build.options.ignored.text = "" }) + }, text) + if got := p.build.options.ignored.text; got != text { + h.t.Fatalf("the ignored-path field holds %q, want %q", got, text) + } +} + +// Detection skips every dot-named folder until the project stores its own list. The Build window's header +// opens the Build options on the ignored paths alone; a list typed there is saved on this machine and the +// build systems are detected again with it, and Restore Default removes it. +func TestBuildOptionsIgnoredPathsAreSavedOnThisMachineAndRestored(t *testing.T) { + h, p := openIgnoredPathsProject(t, "") + if got := buildRoots(p); !slices.Equal(got, []string{".", "tools"}) { + t.Fatalf("by default the build systems are at %q, want the dot-named folder skipped", got) + } + + h.clickNamed(h.shared("desktop.build.optionsTitle")) + h.awaitIgnoredPathsLoaded(p) + e := &p.build.options + if e.instance != "" || len(e.fields) != 0 { + t.Fatalf("the header opened the options of instance %q with %d fields, want the ignored paths alone", e.instance, len(e.fields)) + } + if e.ignored.text != ".*" || !h.showsWrapped(h.shared("desktop.build.ignoredPathsDefault")) { + t.Fatalf("the field holds %q under %q, want the default", e.ignored.text, e.ignored.source) + } + if _, shown := h.findPageHit(h.shared("native.restoreDefault")); shown { + t.Fatal("Restore Default is offered while the default applies") + } + + h.typeIgnoredPaths(p, ".*\n!.claude\n\n/tools") + h.clickNamed(h.shared("ui.save")) + h.settleUntil("the dialog to close", func() bool { return !p.build.options.open }) + h.awaitBuildDetection(p) + if got := buildRoots(p); !slices.Equal(got, []string{".", ".claude/copy"}) { + t.Fatalf("after the edit the build systems are at %q", got) + } + if got := h.storedIgnoredPaths(p); !slices.Equal(got.Patterns, []string{".*", "!.claude", "/tools"}) || got.Source != core.BuildIgnoredPathsLocal { + t.Fatalf("the project stores %+v, want the typed list on this machine", got) + } + + h.clickNamed(h.shared("desktop.build.optionsTitle")) + h.awaitIgnoredPathsLoaded(p) + if !h.showsWrapped(h.shared("desktop.build.ignoredPathsLocal")) { + t.Fatal("the dialog does not say the list is the one saved on this machine") + } + h.clickNamed(h.shared("native.restoreDefault")) + h.awaitIgnoredPathsLoaded(p) + if e := &p.build.options; e.ignored.text != ".*" || !e.ignored.inherit { + t.Fatalf("Restore Default shows %q (inherit %v), want the default list", e.ignored.text, e.ignored.inherit) + } + if got := h.storedIgnoredPaths(p); got.Source != core.BuildIgnoredPathsLocal { + t.Fatalf("Restore Default stored %+v before Save", got) + } + h.clickNamed(h.shared("ui.save")) + h.settleUntil("the dialog to close", func() bool { return !p.build.options.open }) + h.awaitBuildDetection(p) + if got := buildRoots(p); !slices.Equal(got, []string{".", "tools"}) { + t.Fatalf("after Restore Default the build systems are at %q", got) + } + if got := h.storedIgnoredPaths(p); got.Source != core.BuildIgnoredPathsDefault { + t.Fatalf("after Restore Default the project stores %+v, want no list", got) + } +} + +// A build system's options open with the ignored paths above them, here the team file's list. A pattern +// Core refuses keeps the dialog open, names its line in the field and stores nothing. +func TestBuildOptionsNameTheLineOfARefusedIgnoredPath(t *testing.T) { + h, p := openIgnoredPathsProject(t, `{"version":1,"systems":{},"ignoredPaths":["/tools"]}`) + if got := buildRoots(p); !slices.Equal(got, []string{".", ".claude/copy"}) { + t.Fatalf("with the team's list the build systems are at %q", got) + } + + h.clickNamed(h.shared("desktop.build.options")) + h.awaitIgnoredPathsLoaded(p) + e := &p.build.options + if selected := p.selectedBuildInstance(); selected == nil || e.instance != selected.ID || len(e.fields) != 1 || e.name != "Fixture Build" { + t.Fatalf("the group's options opened instance %q named %q with %d fields", e.instance, e.name, len(e.fields)) + } + team := i18n.SharedFormat(h.app.language(), "desktop.build.ignoredPathsTeam", map[string]string{"file": buildTeamOptionsFile}) + if e.ignored.text != "/tools" || !h.showsWrapped(team) { + t.Fatalf("the field holds %q under %q, want the team's list", e.ignored.text, e.ignored.source) + } + + h.typeIgnoredPaths(p, "/tools\n\n# note") + h.clickNamed(h.shared("ui.save")) + h.settleUntil("the refusal", func() bool { return !p.build.options.saving }) + want := i18n.SharedFormat(h.app.language(), "desktop.build.invalidIgnoredPath", map[string]string{"line": "3"}) + if e := &p.build.options; !e.open || e.failure != want || !h.showsWrapped(want) { + t.Fatalf("the refused list left the dialog open %v with %q, want %q", e.open, e.failure, want) + } + if got := h.storedIgnoredPaths(p); got.Source != core.BuildIgnoredPathsTeam { + t.Fatalf("the refused list stored %+v", got) + } +} diff --git a/desktop/internal/workbench/build_options.go b/desktop/internal/workbench/build_options.go index f6ce0ed34..ba3b2459d 100644 --- a/desktop/internal/workbench/build_options.go +++ b/desktop/internal/workbench/build_options.go @@ -5,10 +5,14 @@ import ( "encoding/json" "errors" "slices" + "strconv" + "strings" "github.com/1lck/Lithe-IDEA/desktop/internal/configfile" "github.com/1lck/Lithe-IDEA/desktop/internal/core" "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" + "github.com/1lck/Lithe-IDEA/desktop/internal/icons" + "github.com/vivaui/viva/gfx" "github.com/vivaui/viva/ui" ) @@ -16,7 +20,10 @@ import ( // read only here and never rewritten by this client. The local document is this machine's own preferences for // the project; it is kept in the application's data store, never in the project or in the application bundle. // Core checks the two against each other (build.resolveStoredOptions), applies a change to the local document -// (build.editStoredOption), and detection then reads the merged values back into each instance. +// (build.editStoredOption), and detection then reads the merged values back into each instance. The +// documents also hold the project's ignored paths, the directories detection does not enter: Core picks the +// local list, else the team's, else its default of every dot-named directory (build.resolveStoredOptions), +// and replaces or removes the local list (build.editIgnoredPaths). // buildTeamOptionsFile is the team Build options document, relative to the project root. const buildTeamOptionsFile = ".lithe/build/options.json" @@ -84,7 +91,7 @@ func detectBuildSystemsWithStoredOptions(ctx context.Context, kernel Kernel, roo } // buildDetectOptions maps the merged document onto build.detect's inputs: each system's values, its instance -// defaults, the values of each root and the explicit empty selections. +// defaults, the values of each root, the explicit empty selections and the ignored paths Core picked. func buildDetectOptions(storage core.BuildStoredOptions) (core.BuildDetectOptions, error) { var document struct { Systems map[string]struct { @@ -101,6 +108,7 @@ func buildDetectOptions(storage core.BuildStoredOptions) (core.BuildDetectOption InstanceOptions: map[string]map[string]map[string]json.RawMessage{}, InstanceDefaults: map[string]map[string]json.RawMessage{}, EmptySelections: storage.EmptySelections, + IgnoredPaths: storage.IgnoredPaths.Patterns, } for id, system := range document.Systems { if len(system.Options) > 0 { @@ -116,27 +124,127 @@ func buildDetectOptions(storage core.BuildStoredOptions) (core.BuildDetectOption return inputs, nil } -// buildOptionsEditor is the Build options dialog of the selected instance: its fields, and the instance they -// were read from, so an answer for another instance is dropped. +// buildOptionsEditor is the Build options dialog. Its Detection section edits the project's ignored paths; +// opened from a build system it also edits the options that instance declares. instance is the ID of that +// instance, empty when the dialog was opened from the window's header. session tells this opening of the +// dialog from earlier ones, so an answer that arrives after it was closed or opened again is dropped. type buildOptionsEditor struct { open bool + session int instance string - fields []runOptionField - saving bool - failure string + // name and root name the instance above its options. + name, root string + fields []runOptionField + ignored buildIgnoredPathsEditor + saving bool + failure string } -// openBuildOptions opens the options dialog on the selected instance, from its effective values. -func (a *App) openBuildOptions(p *project) { - instance := p.selectedBuildInstance() - if instance == nil { - return +// buildIgnoredPathsEditor is the ignored-paths field of the dialog, one pattern a line, filled with the list +// Core picked from the stored documents. +type buildIgnoredPathsEditor struct { + // loaded says Core answered; until then the field cannot be edited. + loaded bool + text string + // shown is the list the field was filled with and source the layer it came from. + shown []string + source string + // inherit says the field shows the list that applies without the local one, after Restore Default; + // saving it unchanged removes the local list. + inherit bool + // restoring says Core is working out what Restore Default shows. + restoring bool + // lines are the line numbers of the patterns last saved, so a refused pattern is named by its line. + lines []int +} + +// buildIgnoredPathsChange replaces the local ignored paths with patterns, which came from the given lines of +// the field, or removes the local list when inherit is set. +type buildIgnoredPathsChange struct { + patterns []string + lines []int + inherit bool +} + +// change is what saving the field writes: nothing while it shows the list it was filled with, except after +// Restore Default, when that list is the one without the local list, which saving removes. +func (g *buildIgnoredPathsEditor) change() *buildIgnoredPathsChange { + if !g.loaded { + return nil } - p.build.options = buildOptionsEditor{ - open: true, - instance: instance.ID, - fields: declaredOptionFields(instance.Manifest, instance.BuildSystemID, instance.Options), + patterns, lines := ignoredPathLines(g.text) + if slices.Equal(patterns, g.shown) { + if g.inherit { + return &buildIgnoredPathsChange{inherit: true} + } + return nil } + return &buildIgnoredPathsChange{patterns: patterns, lines: lines} +} + +// ignoredPathLines splits the field into its patterns, one a line, skipping blank lines; lines holds the +// line number of each pattern. Core checks the patterns themselves, so a padded one is sent as typed. +func ignoredPathLines(text string) (patterns []string, lines []int) { + for index, line := range strings.Split(text, "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + patterns, lines = append(patterns, line), append(lines, index+1) + } + return patterns, lines +} + +// openBuildOptions opens the options dialog on the project's ignored paths and, when instance is not nil, +// on that instance's effective option values. The ignored paths are read from the stored documents now. +func (a *App) openBuildOptions(p *project, instance *buildInstance) { + p.build.optionsSessions++ + editor := buildOptionsEditor{open: true, session: p.build.optionsSessions} + if instance != nil { + editor.instance, editor.name, editor.root = instance.ID, buildSystemName(instance), buildRootDetail(instance) + editor.fields = declaredOptionFields(instance.Manifest, instance.BuildSystemID, instance.Options) + } + p.build.options = editor + a.readBuildIgnoredPaths(p, false) +} + +// readBuildIgnoredPaths fills the ignored-paths field with the list Core picks from the stored documents or, +// for Restore Default, with the list that applies once the local one is removed. The documents are read in +// the background with Core's answer; neither stores anything. +func (a *App) readBuildIgnoredPaths(p *project, inherit bool) { + e := &p.build.options + e.ignored.restoring = inherit + kernel, root, session := a.kernel, p.root, e.session + a.background(p, buildRequestTimeout, func(ctx context.Context) func(*App) { + ignored, err := func() (core.BuildIgnoredPaths, error) { + docs, err := a.readBuildOptionDocuments(ctx, root) + if err != nil { + return core.BuildIgnoredPaths{}, err + } + if inherit { + edit, err := kernel.EditBuildIgnoredPaths(ctx, core.BuildIgnoredPathsEditRequest{Document: docs.team, Local: docs.local, Inherit: true}) + return edit.IgnoredPaths, err + } + storage, err := kernel.ResolveStoredBuildOptions(ctx, docs.team, docs.local) + return storage.IgnoredPaths, err + }() + return func(a *App) { + e := &p.build.options + if !e.open || e.session != session { + return + } + e.ignored.restoring = false + if err != nil { + e.failure = err.Error() + return + } + e.failure = "" + e.ignored = buildIgnoredPathsEditor{ + loaded: true, text: strings.Join(ignored.Patterns, "\n"), + shown: ignored.Patterns, source: ignored.Source, inherit: inherit, + } + } + }) } // saveBuildOptions writes the changed fields of the dialog to the local document, one Core edit each, then @@ -146,23 +254,30 @@ func (a *App) saveBuildOptions(p *project) { if !e.open || e.saving { return } + instance := p.buildInstanceByID(e.instance) var changes []buildOptionChange - for _, field := range e.fields { - if value, changed := changedOptionValue(field); changed { - changes = append(changes, buildOptionChange{optionID: field.id, value: value}) + if instance != nil { + for _, field := range e.fields { + if value, changed := changedOptionValue(field); changed { + changes = append(changes, buildOptionChange{optionID: field.id, value: value}) + } } } - instance := p.buildInstanceByID(e.instance) - if instance == nil || len(changes) == 0 { + ignored := e.ignored.change() + if len(changes) == 0 && ignored == nil { e.open = false return } + if ignored != nil { + e.ignored.lines = ignored.lines + } e.saving, e.failure = true, "" - a.saveBuildOptionChanges(p, *instance, changes, func(_ json.RawMessage, err error) { - if e := &p.build.options; e.open { + session := e.session + a.saveBuildOptionChanges(p, instance, changes, ignored, func(_ json.RawMessage, err error) { + if e := &p.build.options; e.open && e.session == session { e.saving = false if err != nil { - e.failure = err.Error() + e.failure = buildOptionsFailure(a.language(), err, e.ignored.lines) return } e.open = false @@ -170,35 +285,64 @@ func (a *App) saveBuildOptions(p *project) { }) } +// buildOptionsFailure is the message of a save that Core refused or the host could not store. A refused +// ignored path is named by its line in the field. +func buildOptionsFailure(language i18n.Language, err error, lines []int) string { + var rejection *core.MarketplaceRejection + if !errors.As(err, &rejection) || rejection.Code != "plugin.build.invalidIgnoredPath" { + return err.Error() + } + index, found := strings.CutPrefix(rejection.Field, "ignoredPaths[") + index, closed := strings.CutSuffix(index, "]") + if at, err := strconv.Atoi(index); found && closed && err == nil && at >= 0 && at < len(lines) { + return i18n.SharedFormat(language, "desktop.build.invalidIgnoredPath", map[string]string{"line": strconv.Itoa(lines[at])}) + } + return i18n.Shared(language, "desktop.build.invalidIgnoredPaths") +} + // buildOptionChange is one option the user set; a nil value removes the local override. type buildOptionChange struct { optionID string value any } -// saveBuildOptionChanges applies changes to one instance's local document. Core checks each edit; the host -// writes the local document only when every edit succeeded, atomically, and then detects the instances again. -// done receives the outcome on the window thread: the option states Core read back from the -// edited documents, or why the edit was refused or could not be stored. -func (a *App) saveBuildOptionChanges(p *project, instance buildInstance, changes []buildOptionChange, done func(options json.RawMessage, err error)) { +// saveBuildOptionChanges applies changes to one instance's options, and the ignored-path change when it is +// not nil, to the project's local document; instance may be nil when there are no option changes. Core checks +// each edit; the host writes the local document only when every edit succeeded, atomically, and then detects +// the instances again. done receives the outcome on the window thread: the option states Core read back from +// the edited documents, or why an edit was refused or could not be stored. +func (a *App) saveBuildOptionChanges(p *project, instance *buildInstance, changes []buildOptionChange, ignored *buildIgnoredPathsChange, done func(options json.RawMessage, err error)) { docs, err := a.readBuildOptionDocuments(context.Background(), p.root) if err != nil { done(nil, err) return } kernel, root := a.kernel, p.root + var target buildInstance + if instance != nil { + target = *instance + } a.background(p, buildRequestTimeout, func(ctx context.Context) func(*App) { local := docs.local var options json.RawMessage for _, change := range changes { - edit, err := kernel.EditStoredBuildOption(ctx, instance.Manifest, instance.BuildSystemID, core.BuildOptionEditRequest{ - Document: docs.team, Local: local, Root: instance.Root, OptionID: change.optionID, Value: change.value, + edit, err := kernel.EditStoredBuildOption(ctx, target.Manifest, target.BuildSystemID, core.BuildOptionEditRequest{ + Document: docs.team, Local: local, Root: target.Root, OptionID: change.optionID, Value: change.value, }) if err != nil { return func(a *App) { done(nil, err) } } local, options = edit.Local, edit.Options } + if ignored != nil { + edit, err := kernel.EditBuildIgnoredPaths(ctx, core.BuildIgnoredPathsEditRequest{ + Document: docs.team, Local: local, Patterns: ignored.patterns, Inherit: ignored.inherit, + }) + if err != nil { + return func(a *App) { done(nil, err) } + } + local = edit.Local + } return func(a *App) { if err := a.storeBuildLocalOptions(root, local); err != nil { done(nil, err) @@ -244,7 +388,8 @@ func (p *project) buildInstanceByID(id string) *buildInstance { return nil } -// buildOptionsDialog declares the Build options dialog while it is open. +// buildOptionsDialog declares the Build options dialog while it is open: the Detection section, then the +// options of the instance it was opened on. func (a *App) buildOptionsDialog(c ui.C, p *project) { e := &p.build.options if !e.open { @@ -255,11 +400,21 @@ func (a *App) buildOptionsDialog(c ui.C, p *project) { open := true closed := ui.ModalWith(c, &open, i18n.Shared(language, "desktop.build.optionsTitle"), ui.ModalSpec{Width: 460}, func() { ui.Column(c.Part("build-options")).FillWidth().Gap(10).Children(func() { - if len(e.fields) == 0 { - a.panelMessage(c, i18n.Shared(language, "desktop.build.noOptions"), a.colors.Subtle) - } - for index := range e.fields { - a.drawRunOptionField(c.KeyInt(index), language, &e.fields[index], ready) + a.buildIgnoredPathsSection(c.Part("detection"), p, ready) + if e.instance != "" { + k := c.Part("instance") + ui.Row(k).FillWidth().Items(ui.Center).Gap(6).MarginTop(6).Children(func() { + a.buildOptionsHeading(k, e.name) + if e.root != "" { + ui.Text(k, e.root).FontSize(a.theme.Metrics.FontSizeS).Color(a.colors.Subtle).Ellipsis().Shrink(1).MinWidth(0) + } + }) + if len(e.fields) == 0 { + ui.Wrap(k, i18n.Shared(language, "desktop.build.noOptions")).FontSize(a.theme.Metrics.FontSizeS).Color(a.colors.Subtle) + } + for index := range e.fields { + a.drawRunOptionField(k.KeyInt(index), language, &e.fields[index], ready) + } } if e.failure != "" { ui.Paragraph(c, e.failure, 4).FontSize(a.theme.Metrics.FontSizeS).Color(a.theme.Danger) @@ -269,8 +424,11 @@ func (a *App) buildOptionsDialog(c ui.C, p *project) { if ui.Button(c, i18n.Shared(language, "desktop.git.cancel")).Outline().Clicked() { c.After(func() { p.build.options = buildOptionsEditor{} }) } - if ui.Button(c, i18n.Shared(language, "ui.save")).Disabled(!ready).Clicked() { - c.After(func() { a.saveBuildOptions(p) }) + if ui.Button(c, i18n.Shared(language, "ui.save")).Disabled(!ready || e.ignored.restoring).Clicked() { + c.After(func() { + a.performed("build.options.save", surfaceButton) + a.saveBuildOptions(p) + }) } }) if closed { @@ -281,3 +439,45 @@ func (a *App) buildOptionsDialog(c ui.C, p *project) { }) } } + +// buildOptionsHeading declares the heading of one section of the Build options dialog. +func (a *App) buildOptionsHeading(c ui.C, title string) { + ui.Text(c, title).FontSize(a.theme.Metrics.FontSizeS).Weight(gfx.WeightSemibold).Color(a.colors.Text).Ellipsis().Shrink(0) +} + +// buildIgnoredPathsSection declares the Detection section of the dialog: the ignored paths, one pattern a +// line, with an ⓘ on how they are written, the layer the shown list comes from, and Restore Default while it +// comes from this machine. +func (a *App) buildIgnoredPathsSection(c ui.C, p *project, ready bool) { + g, language := &p.build.options.ignored, a.language() + label := i18n.Shared(language, "desktop.build.ignoredPaths") + a.buildOptionsHeading(c, i18n.Shared(language, "desktop.build.detection")) + ui.Row(c).FillWidth().Items(ui.Center).Gap(4).Children(func() { + ui.Text(c, label).FontSize(a.theme.Metrics.FontSizeS).Color(a.colors.Text).Shrink(0) + ui.Button(c.Part("help"), i18n.Shared(language, "native.moreInformation")).Icon(icons.Info).IconOnly().Ghost().Size(18, 18). + Tooltip(i18n.Shared(language, "desktop.build.ignoredPathsHelp")).TooltipOn(ui.TooltipRight) + ui.Spacer(c) + if g.source == core.BuildIgnoredPathsLocal && !g.inherit { + if ui.Button(c.Part("restore"), i18n.Shared(language, "native.restoreDefault")).Ghost().Small().Disabled(!ready || g.restoring).Clicked() { + c.After(func() { + a.performed("build.ignoredPaths.restoreDefault", surfaceButton) + a.readBuildIgnoredPaths(p, true) + }) + } + } + }) + ui.TextArea(c.Part("patterns"), &g.text, label, "").FillWidth().Height(72).Disabled(!ready || !g.loaded || g.restoring) + if !g.loaded { + return + } + var source string + switch g.source { + case core.BuildIgnoredPathsLocal: + source = i18n.Shared(language, "desktop.build.ignoredPathsLocal") + case core.BuildIgnoredPathsTeam: + source = i18n.SharedFormat(language, "desktop.build.ignoredPathsTeam", map[string]string{"file": buildTeamOptionsFile}) + default: + source = i18n.Shared(language, "desktop.build.ignoredPathsDefault") + } + ui.Wrap(c, source).FontSize(a.theme.Metrics.FontSizeS).Color(a.colors.Subtle) +} diff --git a/desktop/internal/workbench/build_tree_view.go b/desktop/internal/workbench/build_tree_view.go index 3cce7e8aa..8bfbdf1f0 100644 --- a/desktop/internal/workbench/build_tree_view.go +++ b/desktop/internal/workbench/build_tree_view.go @@ -18,9 +18,9 @@ import ( // console (build_view.go), which an action here brings forward. // buildToolActions declares the Build window's header buttons: run what is chosen, stop the -// running build operation, detect the build systems again, open and close every group. The -// run waits for a task or a runnable target to be chosen; folding waits for the filter to be -// cleared, because a filter shows every match open. +// running build operation, detect the build systems again, open and close every group, and open +// the Build options of the project. The run waits for a task or a runnable target to be chosen; +// folding waits for the filter to be cleared, because a filter shows every match open. func (a *App) buildToolActions(c ui.C, p *project) { b, t, language := &p.build, a.text.Build, a.language() ui.Row(c).Shrink(0).Items(ui.Center).Gap(2).Children(func() { @@ -60,6 +60,12 @@ func (a *App) buildToolActions(c ui.C, p *project) { p.setAllBuildGroups(false) }) } + if _, hit := a.toolIconButton(c.Part("options"), i18n.Shared(language, "desktop.build.optionsTitle"), icons.Settings, false, false); hit { + c.After(func() { + a.performed("build.options.open", surfaceButton) + a.openBuildOptions(p, nil) + }) + } }) } @@ -272,7 +278,7 @@ func (a *App) buildGroupRow(row ui.ListRow, p *project, instance *buildInstance, if options && a.gitIconButton(c.Part("options"), i18n.Shared(language, "desktop.build.options"), icons.Settings, false, false) { c.After(func() { a.performed("build.options.open", surfaceButton) - a.openBuildOptions(p) + a.openBuildOptions(p, p.selectedBuildInstance()) }) } } @@ -308,7 +314,7 @@ func (a *App) buildGroupRow(row ui.ListRow, p *project, instance *buildInstance, c.After(func() { a.performed("build.options.open", surfaceMenu) a.chooseBuildInstance(p, index) - a.openBuildOptions(p) + a.openBuildOptions(p, p.selectedBuildInstance()) }) } }) diff --git a/desktop/internal/workbench/ide_api_build.go b/desktop/internal/workbench/ide_api_build.go index eeb41b193..b6d70eecd 100644 --- a/desktop/internal/workbench/ide_api_build.go +++ b/desktop/internal/workbench/ide_api_build.go @@ -38,7 +38,7 @@ func (a *App) ideAPIBuildConfigure(p *project, instanceID, optionID string, raw } } answer := make(chan any, 1) - a.saveBuildOptionChanges(p, *instance, []buildOptionChange{{optionID: optionID, value: value}}, func(options json.RawMessage, err error) { + a.saveBuildOptionChanges(p, instance, []buildOptionChange{{optionID: optionID, value: value}}, nil, func(options json.RawMessage, err error) { answer <- ideAPIConfiguredOption(instanceID, optionID, options, err) }) return answer, nil diff --git a/scripts/desktop/build-desktop-core.sh b/scripts/desktop/build-desktop-core.sh index 0241adf09..8a119cf4c 100755 --- a/scripts/desktop/build-desktop-core.sh +++ b/scripts/desktop/build-desktop-core.sh @@ -28,9 +28,13 @@ native_path() { } PINNED_CORE_REPOSITORY="https://github.com/Lithe-IDEA/Lithe-core-rust.git" -# Lithe-core-rust #34, squash-merged into main: workbench.chromeLayout.reduce setHidden hides a -# stripe entry and the layout keeps it in hidden. It is on top of #33, squash-merged into -# main: build.detect and automatic run discovery skip +# Lithe-core-rust #35, squash-merged into main: build.detect skips the workspace's ignored paths, +# .gitignore patterns relative to the root that only match directories, by default every +# dot-named directory; build.resolveStoredOptions reports the list the local or team Build +# document sets, else the default, and build.editIgnoredPaths replaces or removes the local list. +# It is on top of #34 (workbench.chromeLayout.reduce setHidden hides a stripe entry and the +# layout keeps it in hidden), squash-merged into main, on top of #33, squash-merged into main: +# build.detect and automatic run discovery skip # directories Git ignores (the .gitignore files inside the workspace and the info/exclude of each # repository rooted there, a linked worktree's shared one included), so agent worktrees and local # copies are no longer build or run roots; fingerprint scans are unchanged. It is on top of #32 @@ -63,7 +67,7 @@ PINNED_CORE_REPOSITORY="https://github.com/Lithe-IDEA/Lithe-core-rust.git" # the contract, plugin guides, reference and SDKs live only in shared/plugins; on top of #16 (reported Git queries folded in the console), # #15 (git.write cherryPick, revert and reset fields), #14 (word-level changed ranges of diff # rows), #13, #8, #7, #6 and #4. -PINNED_CORE_REVISION="1d4556b09d156ad93724368750cddd32a49ca09f" +PINNED_CORE_REVISION="d19e9ef77171aeb408a7c12c0bdee088b3bba591" PROFILE="debug" while [[ $# -gt 0 ]]; do case "$1" in diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 8f3470c3d..37f285eeb 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -10798,6 +10798,14 @@ "desktop.build.options": "Options", "desktop.build.optionsTitle": "Build Options", "desktop.build.noOptions": "This build system declares no options.", + "desktop.build.detection": "Detection", + "desktop.build.ignoredPaths": "Ignored Paths", + "desktop.build.ignoredPathsHelp": "One rule per line, written as in .gitignore and relative to the project root; rules match folders only. Later rules win, and a rule starting with ! brings a folder back into detection, even one Git ignores. By default every folder whose name starts with a dot, such as .claude/worktrees, is skipped; dependency caches and build outputs are always skipped. At most 256 rules.", + "desktop.build.ignoredPathsLocal": "Using the list saved on this machine.", + "desktop.build.ignoredPathsTeam": "Using the list in the team file {file}; changes are saved on this machine only.", + "desktop.build.ignoredPathsDefault": "Using the default list; changes are saved on this machine only.", + "desktop.build.invalidIgnoredPath": "Line {line} is not a usable ignore rule. Rules follow .gitignore syntax, have no leading or trailing spaces, do not start with # or consist of a lone !, are at most 1024 bytes long, and number at most 256.", + "desktop.build.invalidIgnoredPaths": "These ignore rules cannot be used together. Check how they are written.", "desktop.command.addNextOccurrence": "Add Next Occurrence", "desktop.command.unselectOccurrence": "Unselect Occurrence", "desktop.command.selectAllOccurrences": "Select All Occurrences", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 8b2954712..0a7e68151 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -10795,9 +10795,17 @@ "desktop.debug.registers": "寄存器", "desktop.runConfig.optionDefault": "默认", "desktop.runConfig.optionReadOnly": "此处无法编辑", - "desktop.build.options": "构建选项", + "desktop.build.options": "选项", "desktop.build.optionsTitle": "构建选项", "desktop.build.noOptions": "此构建系统没有声明可配置的选项。", + "desktop.build.detection": "检测", + "desktop.build.ignoredPaths": "忽略路径", + "desktop.build.ignoredPathsHelp": "一行一条规则,写法同 .gitignore,相对项目根目录,只匹配文件夹。后面的规则优先,以 ! 开头的规则让文件夹重新参与检测,包括被 Git 忽略的文件夹。默认忽略所有以 . 开头的文件夹,例如 .claude/worktrees;依赖缓存和构建输出始终跳过。最多 256 条。", + "desktop.build.ignoredPathsLocal": "当前使用本机保存的设置。", + "desktop.build.ignoredPathsTeam": "当前使用团队文件 {file} 中的设置;修改后只保存在本机。", + "desktop.build.ignoredPathsDefault": "当前使用默认设置;修改后只保存在本机。", + "desktop.build.invalidIgnoredPath": "第 {line} 行不是可用的忽略规则。规则要符合 .gitignore 写法,首尾不能有空格,不能以 # 开头或只写 !,每条最长 1024 字节,最多 256 条。", + "desktop.build.invalidIgnoredPaths": "这些忽略规则不能一起使用,请检查写法。", "desktop.command.addNextOccurrence": "添加下一处匹配", "desktop.command.unselectOccurrence": "取消上一处匹配", "desktop.command.selectAllOccurrences": "选择所有匹配", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index ae1c1a9e1..0a58331c3 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -6692,14 +6692,76 @@ }, { "id": "desktop.build.options", - "description": "Build tool window button that opens the Build options of the selected instance", + "description": "Build tool window button and menu item that open the Build options of one build system, beside the project-wide ones", "platforms": [ "desktop" ] }, { "id": "desktop.build.optionsTitle", - "description": "Title of the Build options dialog", + "description": "Title of the Build options dialog and of the Build tool window header button that opens it", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.build.detection", + "description": "Heading of the Build options dialog section that sets how build systems are detected", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.build.ignoredPaths", + "description": "Label of the Build options field that lists the folders build detection skips, one .gitignore pattern a line", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.build.ignoredPathsHelp", + "description": "Tip of the ⓘ beside the ignored paths field: how the patterns are written and what is skipped by default", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.build.ignoredPathsLocal", + "description": "Caption under the ignored paths field when the list shown is the one saved on this machine", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.build.ignoredPathsTeam", + "description": "Caption under the ignored paths field when the list shown comes from the team Build options file; file is that file's path relative to the project", + "platforms": [ + "desktop" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "desktop.build.ignoredPathsDefault", + "description": "Caption under the ignored paths field when no Build options file sets the list and the default applies", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.build.invalidIgnoredPath", + "description": "Build options error when one ignored path is not a usable pattern; line is its line in the field", + "platforms": [ + "desktop" + ], + "parameters": { + "line": "string" + } + }, + { + "id": "desktop.build.invalidIgnoredPaths", + "description": "Build options error when the ignored paths are each valid but cannot be used together", "platforms": [ "desktop" ] diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index dc3714c15..9090b7b1b 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -31,7 +31,10 @@ "desktop/internal/workbench/build_tree_test.go", "desktop/internal/workbench/build_menus.go", "desktop/internal/workbench/build_menus_test.go", - "desktop/internal/workbench/catalog.go" + "desktop/internal/workbench/catalog.go", + "desktop/internal/core/build_options.go", + "desktop/internal/workbench/build_options.go", + "desktop/internal/workbench/build_ignored_paths_test.go" ] }, "windows": { @@ -62,12 +65,15 @@ "desktop/internal/workbench/build_tree_test.go", "desktop/internal/workbench/build_menus.go", "desktop/internal/workbench/build_menus_test.go", - "desktop/internal/workbench/catalog.go" + "desktop/internal/workbench/catalog.go", + "desktop/internal/core/build_options.go", + "desktop/internal/workbench/build_options.go", + "desktop/internal/workbench/build_ignored_paths_test.go" ] }, "owner": "Extensions", "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/architecture/verify-shared-contracts.sh、./scripts/macos/test/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/desktop/verify/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实 Maven 模块编译与单方法测试已有生产 macOS Provider 会话证据;Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。 Maven 独立插件的本地增量已经删除生命周期/插件目标/自定义目标/测试的命令模板,改由 Provider 3 执行;真实 Core C ABI + Java 后端 + Maven 三模块工程验证模块编译、单方法 Surefire 报告和模型共享,受控进程回归覆盖取消、非零退出和输出超限。插件与新版 SDK 已分别提交 Draft PR #7/#4,尚未发布,正式 SDK 锁定仍待更新;生产 macOS Provider 会话已经执行模型、编译和单方法测试,包 SHA-256/权限保持不变;显式 SDK CI 在 Java 8 上通过当前与 3.6.3 Maven 的 55 项开发检查(各自包括 3 项打包);旧 MavenService、Agent/设置/Run/Java 迁移、正式包、完整 macOS UI 与 Windows 验收未完成,保持 partial/pending。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 Build 树应用从文档保存阶段起持有同一可取消运行 ID;保存 gate 下启动新任务/测试或停止、切换工作区、选项/源码/模型/插件失效后,旧保存返回不得启动 Provider、替换新运行或清空新输出;保存失败不启动,成功后才准备。 原生视图隔离回归用真实 Core 与受控 Provider 数据检查深浅主题的测试发现、Debug 入口和 Run 输出;截图仅验证该视图状态,不代替完整发行包或 Windows 实机验收。 实例作用域:用同一工作区的两个独立根验证 Profiles、文件/目录、布尔与文本值独立持久化;编辑、清空或重置 A 不影响 B,重开后任务、静态运行入口、同步和语言导入读取相同根的配置;未声明作用域的插件保留共享行为。旧 Maven 数据迁移与 Windows 运行验收另行完成,保持 partial/pending。 作用域变更的旧 Build 偏好先复制并验证目标,再删除共享源;写入失败阻止默认配置发布并允许重试,已有实例值优先,暂未检测到根的空标记也迁移保留。 工具链候选通过真实 Core 与生产本地文档验证按实例选择 Maven/JDK,Provider 环境保留选中的 JAVA_HOME/PATH 且不转发无关测试凭据;被动导入不探测版本或运行 Wrapper,显式路径失败不能独立发现。Windows 不声明 executableResolution 2,旧 Maven 服务迁移仍待完成。 共享 Build 文档验证工作区/实例默认值、逐根覆盖和本机差异合并;重开和检测不改共享字节,重置本机值恢复继承,外部修改冲突或文档损坏明确失败并保留原始数据。 未选中的自定义候选仅在 allowCustom 选项中展示,不改变选择和模型;真实 Core、生产存储和服务回归通过,新增实例静态入口继承团队默认值。 协议 8 回归覆盖协商拒绝、随执行返回的报告、缺失/损坏失败、模块同名测试隔离、取消/配置变更后拒绝晚到解码,以及普通任务中的测试结果。Maven 开发包用官方已配置测试 Mojo 的报告目录和 Apache Surefire parser,真实 Core/Maven 覆盖带空格自定义目录与非零退出的失败用例,坏 XML/外部实体拒绝,包字节保持不变。Java/Node SDK 同步,正式发布和全部旧 Maven/Java 消费者迁移尚未完成。 本机选项、逐根映射和空多选标记中存在非 Data 存储值时,检测失败并保留原始偏好,不得当缺失键发布默认值。 本机实例默认值继承、逐根覆盖与重置通过真实 Core 和生产存储验证,团队文档字节不变;本机默认值不进入资源复用。 固定宿主 ac24a96af 与 SDK 71667af 的 Java 8 CI(https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40792694290)在当前 Maven/3.6.3 各 72 项通过,覆盖新原生配置与 Surefire/Failsafe 报告、取消清理和包字节不变;正式发行组合仍待验证。 协议 10 接通带版本协商的类型化运行选项与服务/任务准备(Core、Swift、Node/Java SDK)。原生 Run/Start All 为每个 Maven 服务独立准备,配置 ABA/Stop/重置拒绝旧回复,已停进程输出不能覆盖新会话;Build 树按服务/任务类型发请求;停用和包更新在删除包前停止准备及进程,旧启动注册更新后不可再次使用。后端复用 Maven reactor 和 Mojo 执行器,真实 Core→Maven 验证未安装兄弟模块、精确 argv、cwd、目标撤销和包哈希不变。macOS 无调用者的旧 Maven 桥和工程模型已删除;Windows 当前旧命令消费者、Core 旧实现和正式发行/完整 UI 验收仍待完成。 保存 Provider service/task 引用保留执行类型、稳定配置 ID 和参数,重开后按当前声明重验;独立准备至进程交接保留 Agent 操作身份。 服务所有者使用完整插件包身份;停用其中一个插件不会停止同名构建系统下另一插件的进程。preview 内存检测层保留 Provider 模型摘要,自动识别不写工作区。 后续本机真实 Micronaut HTTP 应用(Platform 4.7.6 / Core 与注解处理器 4.7.14)在 JVM 暂停后经 JDI 恢复,通过 Netty 控制器和编译期注入服务读取未安装的 reactor 兄弟依赖,参数、环境及包字节不变;这是旧固定 CI 之后的本地四步框架增量,不代替 Debug UI、正式包或 Windows 原生验收。 Windows Build 选项编辑与 Agent 共用原生本机存储:开页只读,修改草稿后显式保存/恢复继承;隐藏重开保留草稿。团队/本机两层版本同时检查,冲突、关闭、插件撤销拒绝迟到写入。实例选择独立,工作区多选保留兄弟实例值和显式空选择;无关系统/根/记忆选项与团队文件字节保持。保存后重新检测并使旧模型失效。长选项/模型选择使用有界视口,不启动工具或 Provider。产品能力准入仍关闭,不能据此认定 Windows 完整 Maven 产品迁移完成。 Viva 2026-11-09:Tests 工具窗口经 build.testPlan 计划工作区测试,宿主只启动 Core 展开的命令或 Provider 请求;报告由 Core build.decodeTestResults 解码,最终判定由 build.resolveTestRunOutcome 归约,宿主不解析报告格式、不按退出码自行判定。结果树按 suite/case 分组并有界虚拟化,失败项按解码出的 workspace-relative path/line 打开;停止、工作区关闭和过期结果按 run generation 丢弃。desktop/internal/workbench/tests_test.go 以隔离 fixture 插件、真实测试进程和 JUnit fixture 覆盖运行→解码→结果树→失败定位、停止取消和关闭项目停止运行;desktop/internal/workbench/tests_provider_test.go 用真实 Provider 进程(lithe/build/test 返回规范报告)覆盖 Provider 运行→Core 解码→结果树、Provider 取消和关闭项目停止 Provider 会话;原生窗口、真实 Java/Cargo 插件组合与 Windows 实机仍 pending。 Viva 聚合 Build 窗口:build_tree_test.go 覆盖默认只展开选中实例、用户展开/折叠的记忆、全部展开时依赖保持折叠、筛选按分组/根目录/行匹配且不触发加载、同时最多加载 4 个模型、失败和折叠的分组不加载,以及点击其他分组的行切换构建系统并能运行选中的目标;build_menus_test.go 覆盖从右键菜单运行目标后打开 Run 窗口的构建控制台;runconfig_browser_test.go 覆盖 Build 窗口中的运行配置列表:范围与计数、固定写入客户端存储而不写项目、分组折叠展开、双击运行和行内停止。运行 scripts/desktop/desktop-go.sh test ./internal/workbench -run 'TestBuild|TestRunConfiguration' -count=1。真实插件组合与 Windows 实机仍待验收。 Viva 2026-11-14 任务范围:构建树的任务行不带项目,从树里运行时按任务声明的范围推断,不再把只能在项目上运行的任务当作工作区请求(Core 会报 plugin.build.invalidSelection)。模型任务用它所属的项目;声明任务依次用选中的目标、选中的项目(或目标所在的项目)、工作区;什么都没选时取构建根目录的项目或唯一的项目、唯一适用的目标;推断不出时提示先选择项目或目标,不发请求。选中项目时,任务分组只列出这个项目的模型任务。IDE API 仍按请求原样交给 Core 校验。build_tree_test.go 的 TestBuildTaskSelectionFitsDeclaredScopes、TestBuildTreeTasksRunOnTheScopeTheyDeclare 覆盖这些规则,后者在修复前失败(Project 为空);真实插件组合与 Windows 实机仍待验收。", - "notes": "Viva 已接入 buildSystems 1 / buildProviders 1–2 的纵向切片:Build 工具窗口用 Core 检测实例、加载 provider 模型或执行 command 模型,列出项目、目标、command/provider 任务;command 任务按 build.invocationPlan 的候选顺序解析并做版本检查,provider 任务经 lsp.buildRequest 执行,共享 provider 复用同插件语言服务器;输出有界、支持停止,关闭项目回收 provider 与进程树。真实 macOS 验证使用官方 Cargo 0.1.0 插件内容,按根目录重打包后安装、启用、模型加载与 cargo build 跑通;GitHub Release 原始包按契约 / 目录布局发布,当前 pluginpkg 只接受解压根目录的 plugin.json,测试使用根目录重打包的同一包,安装器布局问题单独记录。未声明 buildProviders 4、settings、executableResolution、测试与 Debug:工具链运行引用、需设置插件、测试/诊断面板仍缺失;Maven 0.1.1 的 languageImport 与固定 Core 不兼容。Windows Job Object 已实现并交叉编译,但无实机验证,保持 pending。Viva 客户端尚未逐项盘点;legacy 是迁移前基线,不代表当前实现。 Viva 运行设置接线:build.invocationPlan 携带真实全局插件层,显式无效工具路径直接失败并保留自动候选顺序;有效显式工具父目录进入 PATH。Provider 启动携带独立全局/项目层,工作区 URI 由 Core 生成。此接线不表示通用设置界面、原生配置页或完整 Build 协议迁移已验收。 Viva 2026-11-14:构建系统改为一个聚合的 Build 工具窗口,放在右侧工具栏上方(与 IDEA 的位置一致),不再为每个实例开标签页。窗口里是一棵树,每个检测到的构建系统实例是一个分组,显示构建系统名称、徽标和相对根目录;分组下是项目、目标、任务、测试和依赖。默认只展开当前选中的实例,用户手动展开或折叠过的分组保持该状态;依赖分组默认折叠,因为加载依赖要运行构建工具。筛选框按分组名称、根目录和行名称过滤,匹配的行固定展开在所属分组下;筛选只读已加载的模型,不会触发加载。展开的分组按需加载模型,同时最多加载 4 个,加载失败的不会自动重试,折叠的分组不加载。点击其他分组的行会切换到该构建系统并选中这一行,工具栏的运行按钮会执行选中的目标或任务。运行配置列表放在右侧 Build 窗口顶部,和下面的构建树之间有可拖动的分隔条,按范围、固定和执行类型筛选,双击运行、行内按钮停止。目标的名称以其路径结尾时(如 Go 包的导入路径以所在目录结尾),目标行只显示路径,鼠标悬停显示完整名称;其他目标仍显示名称并在旁边显示路径,筛选仍按名称和路径匹配。底部原先的 Build 窗口改为 Run 窗口,只显示运行输出:构建控制台,以及插件启动的运行各自的标签页;Build 窗口发起的任务或运行会打开 Run 窗口并切到构建控制台。 Viva 2026-11-14:参照 IDEA 的 Make 窗口,每个任务和有运行入口的目标行首常驻绿色运行按钮,不必先选中,点一下就选中该行并运行,构建进行中按钮变暗不可点;没有运行入口的目标和项目保留类型图标,调试按钮仍只在选中的行上。名称与路径放不下时路径先省略、名称以省略号结尾,悬停提示显示完整名称和路径(只显示路径的目标显示完整名称);依赖标题旁的 ⓘ 说明依赖树在第一次展开时才由构建工具读取,以及何时重新读取。自动化:TestBuildTreeRunMarksRunTheirRowsAtOnce;截图 docs/assets/build-panels/build-go-targets-*.png。 Viva 2026-11-14:Core 固定版本升到 Lithe-core-rust#33,构建检测和自动运行项发现跳过被 Git 忽略的目录(工作区内的 .gitignore 与仓库的 info/exclude),代理工作树、生成目录和本地副本不再被列成构建系统,同一模块不再出现两遍;Core 自动化:tests::build_systems::detection_skips_directories_git_ignores。", + "notes": "Viva 已接入 buildSystems 1 / buildProviders 1–2 的纵向切片:Build 工具窗口用 Core 检测实例、加载 provider 模型或执行 command 模型,列出项目、目标、command/provider 任务;command 任务按 build.invocationPlan 的候选顺序解析并做版本检查,provider 任务经 lsp.buildRequest 执行,共享 provider 复用同插件语言服务器;输出有界、支持停止,关闭项目回收 provider 与进程树。真实 macOS 验证使用官方 Cargo 0.1.0 插件内容,按根目录重打包后安装、启用、模型加载与 cargo build 跑通;GitHub Release 原始包按契约 / 目录布局发布,当前 pluginpkg 只接受解压根目录的 plugin.json,测试使用根目录重打包的同一包,安装器布局问题单独记录。未声明 buildProviders 4、settings、executableResolution、测试与 Debug:工具链运行引用、需设置插件、测试/诊断面板仍缺失;Maven 0.1.1 的 languageImport 与固定 Core 不兼容。Windows Job Object 已实现并交叉编译,但无实机验证,保持 pending。Viva 客户端尚未逐项盘点;legacy 是迁移前基线,不代表当前实现。 Viva 运行设置接线:build.invocationPlan 携带真实全局插件层,显式无效工具路径直接失败并保留自动候选顺序;有效显式工具父目录进入 PATH。Provider 启动携带独立全局/项目层,工作区 URI 由 Core 生成。此接线不表示通用设置界面、原生配置页或完整 Build 协议迁移已验收。 Viva 2026-11-14:构建系统改为一个聚合的 Build 工具窗口,放在右侧工具栏上方(与 IDEA 的位置一致),不再为每个实例开标签页。窗口里是一棵树,每个检测到的构建系统实例是一个分组,显示构建系统名称、徽标和相对根目录;分组下是项目、目标、任务、测试和依赖。默认只展开当前选中的实例,用户手动展开或折叠过的分组保持该状态;依赖分组默认折叠,因为加载依赖要运行构建工具。筛选框按分组名称、根目录和行名称过滤,匹配的行固定展开在所属分组下;筛选只读已加载的模型,不会触发加载。展开的分组按需加载模型,同时最多加载 4 个,加载失败的不会自动重试,折叠的分组不加载。点击其他分组的行会切换到该构建系统并选中这一行,工具栏的运行按钮会执行选中的目标或任务。运行配置列表放在右侧 Build 窗口顶部,和下面的构建树之间有可拖动的分隔条,按范围、固定和执行类型筛选,双击运行、行内按钮停止。目标的名称以其路径结尾时(如 Go 包的导入路径以所在目录结尾),目标行只显示路径,鼠标悬停显示完整名称;其他目标仍显示名称并在旁边显示路径,筛选仍按名称和路径匹配。底部原先的 Build 窗口改为 Run 窗口,只显示运行输出:构建控制台,以及插件启动的运行各自的标签页;Build 窗口发起的任务或运行会打开 Run 窗口并切到构建控制台。 Viva 2026-11-14:参照 IDEA 的 Make 窗口,每个任务和有运行入口的目标行首常驻绿色运行按钮,不必先选中,点一下就选中该行并运行,构建进行中按钮变暗不可点;没有运行入口的目标和项目保留类型图标,调试按钮仍只在选中的行上。名称与路径放不下时路径先省略、名称以省略号结尾,悬停提示显示完整名称和路径(只显示路径的目标显示完整名称);依赖标题旁的 ⓘ 说明依赖树在第一次展开时才由构建工具读取,以及何时重新读取。自动化:TestBuildTreeRunMarksRunTheirRowsAtOnce;截图 docs/assets/build-panels/build-go-targets-*.png。 Viva 2026-11-14:Core 固定版本升到 Lithe-core-rust#33,构建检测和自动运行项发现跳过被 Git 忽略的目录(工作区内的 .gitignore 与仓库的 info/exclude),代理工作树、生成目录和本地副本不再被列成构建系统,同一模块不再出现两遍;Core 自动化:tests::build_systems::detection_skips_directories_git_ignores。 Viva 2026-11-14:Core 固定版本升到 Lithe-core-rust#35,构建选项新增“检测”分区的忽略路径:一行一条 .gitignore 写法的规则,相对项目根目录、只匹配文件夹,默认忽略所有以 . 开头的文件夹(如 .claude/worktrees),以 ! 开头的规则可以让文件夹(包括被 Git 忽略的)重新参与检测。列表按本机、团队文件 .lithe/build/options.json、默认值的顺序取第一个,对话框写明当前来源;修改只保存在本机,恢复默认删除本机列表,保存后重新检测。Build 窗口标题栏的齿轮打开只含忽略路径的构建选项,构建系统行的齿轮和右键菜单在忽略路径下方显示该构建系统的选项;Core 拒绝的规则标出所在行,对话框保持打开、不写入。自动化:TestBuildOptionsIgnoredPathsAreSavedOnThisMachineAndRestored、TestBuildOptionsNameTheLineOfARefusedIgnoredPath、core 包 TestBuildIgnoredPathsResolveFromTheLayersAndEditTheLocalList、TestBuildDetectSkipsTheIgnoredPaths。", "legacy": { "revision": "5a54c41aa37a4e6e6b68918a4b05765b9dd07a36", "macos": {