diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 665ab51673..2fcf4dc877 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -688,6 +688,7 @@ "Clear Git console" = "Clear Git console"; "Clear Logs" = "Clear Logs"; "Clear Override" = "Clear Override"; +"Clear Project History…" = "Clear Project History…"; "Clear branch filter" = "Clear branch filter"; "Clear build output" = "Clear build output"; "Clear collected logs?" = "Clear collected logs?"; @@ -702,7 +703,9 @@ "Clear test output" = "Clear test output"; "Clear the current project's Java index and rebuild it on next use" = "Clear the current project's Java index and rebuild it on next use"; "Clear the override and inherit" = "Clear the override and inherit"; +"Clear this project's local history?" = "Clear this project's local history?"; "Clear user filter" = "Clear user filter"; +"Clearing…" = "Clearing…"; "Click the editor gutter to add a breakpoint" = "Click the editor gutter to add a breakpoint"; "Click the folded options to expand; right-click for command details." = "Click the folded options to expand; right-click for command details."; "Client: %@" = "Client: %@"; @@ -971,6 +974,7 @@ "Copy worktree path" = "Copy worktree path"; "Could not check for updates" = "Could not check for updates"; "Could not check for updates." = "Could not check for updates."; +"Could not clear this project's local history. Check storage permissions and try again." = "Could not clear this project's local history. Check storage permissions and try again."; "Could not compare the local adapter with the verified version." = "Could not compare the local adapter with the verified version."; "Could not confirm" = "Could not confirm"; "Could not connect to the Debug Adapter: %@" = "Could not connect to the Debug Adapter: %@"; @@ -1420,6 +1424,7 @@ "Evaluate expression" = "Evaluate expression"; "Evaluate expression while paused" = "Evaluate expression while paused"; "Evaluating…" = "Evaluating…"; +"Every saved version of this project's files is deleted. Open files are not changed." = "Every saved version of this project's files is deleted. Open files are not changed."; "Example" = "Example"; "Exception" = "Exception"; "Exception Breakpoints" = "Exception Breakpoints"; @@ -1639,6 +1644,8 @@ "Git version" = "Git version"; "Git was not found" = "Git was not found"; "Git work is still active." = "Git work is still active."; +"Git: ignored" = "Git: ignored"; +"Git: not ignored" = "Git: not ignored"; "GitHub authorization failed:" = "GitHub authorization failed:"; "GitHub authorization failed: %@" = "GitHub authorization failed: %@"; "GitHub authorization was cancelled." = "GitHub authorization was cancelled."; @@ -1698,6 +1705,7 @@ "Headers" = "Headers"; "Heap Memory" = "Heap Memory"; "Hexadecimal" = "Hexadecimal"; +"Hidden in the project tree, not recorded" = "Hidden in the project tree, not recorded"; "Hidden paths" = "Hidden paths"; "Hide %@ tool window" = "Hide %@ tool window"; "Hide API key" = "Hide API key"; @@ -1763,6 +1771,8 @@ "If: %@" = "If: %@"; "Ignore whitespace" = "Ignore whitespace"; "Ignored" = "Ignored"; +"Ignored by regular expression line %lld" = "Ignored by regular expression line %lld"; +"Ignored by wildcard rule %@" = "Ignored by wildcard rule %@"; "Ignored entries: %lld" = "Ignored entries: %lld"; "Image zoom" = "Image zoom"; "Images can only be pasted into Markdown documents" = "Images can only be pasted into Markdown documents"; @@ -1987,7 +1997,9 @@ "License" = "License"; "Lifecycle" = "Lifecycle"; "Light" = "Light"; +"Limit per project" = "Limit per project"; "Line" = "Line"; +"Line %lld is not a valid regular expression and is skipped." = "Line %lld is not a valid regular expression and is skipped."; "Line Breakpoints" = "Line Breakpoints"; "Line breakpoint actions" = "Line breakpoint actions"; "Line numbers" = "Line numbers"; @@ -2099,7 +2111,11 @@ "Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation." = "Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation."; "Local development package" = "Local development package"; "Local edits to these files are lost." = "Local edits to these files are lost."; +"Local history" = "Local history"; +"Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`." = "Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`."; +"Local history limit per project" = "Local history limit per project"; "Local history work is still active." = "Local history work is still active."; +"Local history: %@" = "Local history: %@"; "Location" = "Location"; "Lock Worktree" = "Lock Worktree"; "Locked" = "Locked"; @@ -2554,6 +2570,7 @@ "One file: %@" = "One file: %@"; "One path per line. Relative paths are resolved from the workspace." = "One path per line. Relative paths are resolved from the workspace."; "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply." = "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply."; +"One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them." = "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them."; "One pattern per line, for example org.mockito.* or com.example.generated.*" = "One pattern per line, for example org.mockito.* or com.example.generated.*"; "One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window." = "One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window."; "One sentence focused on the main change" = "One sentence focused on the main change"; @@ -2616,6 +2633,7 @@ "Open a project to configure its run configurations." = "Open a project to configure its run configurations."; "Open a project to let connected agents inspect and control it." = "Open a project to let connected agents inspect and control it."; "Open a project to manage its skills." = "Open a project to manage its skills."; +"Open a project to see how much space its history uses." = "Open a project to see how much space its history uses."; "Open a project to see its language service." = "Open a project to see its language service."; "Open a project to use Git" = "Open a project to use Git"; "Open a source file before running Current File." = "Open a source file before running Current File."; @@ -2969,6 +2987,7 @@ "Reconnect this Agent to refresh its model list." = "Reconnect this Agent to refresh its model list."; "Reconnecting %d/%d…" = "Reconnecting %d/%d…"; "Reconnecting…" = "Reconnecting…"; +"Recorded" = "Recorded"; "Recovery reference: %@" = "Recovery reference: %@"; "Reference changes could not be inspected" = "Reference changes could not be inspected"; "Reference list truncated; total updated: %lld, deleted: %lld" = "Reference list truncated; total updated: %lld, deleted: %lld"; @@ -3003,6 +3022,8 @@ "Registration" = "Registration"; "Registry website" = "Registry website"; "Regular Expression" = "Regular Expression"; +"Regular expression" = "Regular expression"; +"Regular expression ignore rules" = "Regular expression ignore rules"; "Reinstall" = "Reinstall"; "Relative" = "Relative"; "Release Date" = "Release Date"; @@ -3799,6 +3820,7 @@ "Test" = "Test"; "Test Connection" = "Test Connection"; "Test connection" = "Test connection"; +"Test path" = "Test path"; "Test process tree · Resident memory" = "Test process tree · Resident memory"; "Test the connection to see the host's system, resources, and address." = "Test the connection to see the host's system, resources, and address."; "Testing" = "Testing"; @@ -4324,6 +4346,7 @@ "Type" = "Type"; "Type / to see commands" = "Type / to see commands"; "Type / to see options" = "Type / to see options"; +"Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history." = "Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history."; "Type changed" = "Type changed"; "Type your task or / for commands" = "Type your task or / for commands"; "Type your task, / for commands or @ for files" = "Type your task, / for commands or @ for files"; @@ -4479,6 +4502,7 @@ "Use this when a repository or Git default needs a one-off adjustment." = "Use this when a repository or Git default needs a one-off adjustment."; "Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "Use up to 64 lowercase letters, digits and single hyphens for the skill name."; "Use your local ChatGPT account. No API key or URL is needed." = "Use your local ChatGPT account. No API key or URL is needed."; +"Used" = "Used"; "Used %@" = "Used %@"; "Used %lld tool" = "Used %lld tool"; "Used %lld tools" = "Used %lld tools"; @@ -4567,6 +4591,7 @@ "When quitting Lithe" = "When quitting Lithe"; "When should this skill be used?" = "When should this skill be used?"; "When submitting a prompt" = "When submitting a prompt"; +"When the limit is reached, the oldest versions are removed first." = "When the limit is reached, the oldest versions are removed first."; "When to run" = "When to run"; "Where Push goes when you do not choose a target explicitly." = "Where Push goes when you do not choose a target explicitly."; "Where should this apply?" = "Where should this apply?"; @@ -4574,6 +4599,8 @@ "Where would you like to open the project ‘%@’?" = "Where would you like to open the project ‘%@’?"; "Whitespace comparison" = "Whitespace comparison"; "Whole Words" = "Whole Words"; +"Wildcard" = "Wildcard"; +"Wildcard ignore rules" = "Wildcard ignore rules"; "Window" = "Window"; "Withdrawn" = "Withdrawn"; "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you." = "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you."; @@ -4765,6 +4792,7 @@ "git.rollback.warning" = "Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe."; "git.status.refreshFailed" = "Could not refresh Git status. Refresh to verify the changes."; "git.unifiedView" = "Unified view"; +"history.clearFailed" = "Could not clear this project's local history. Check storage permissions and try again."; "https://github.com/owner/repository" = "https://github.com/owner/repository"; "java.launch.error.buildCancelled" = "The Java project build was cancelled before it finished."; "java.launch.error.buildFailed" = "The Java language service could not complete the project build. Check the Java language server log for the build error."; @@ -5344,13 +5372,16 @@ "native.clearLogs" = "Clear Logs"; "native.clearOverride" = "Clear Override"; "native.clearPathFilter" = "Clear path filter"; +"native.clearProjectHistory" = "Clear Project History…"; "native.clearRunOutput" = "Clear run output"; "native.clearSearch" = "Clear search"; "native.clearSelection" = "Clear selection"; "native.clearTestOutput" = "Clear test output"; "native.clearTheCurrentProjectSJavaIndexAndRebuildItOnNextUse" = "Clear the current project's Java index and rebuild it on next use"; "native.clearTheOverrideAndInherit" = "Clear the override and inherit"; +"native.clearThisProjectSLocalHistory" = "Clear this project's local history?"; "native.clearUserFilter" = "Clear user filter"; +"native.clearing" = "Clearing…"; "native.cliUpdatedTo" = "CLI updated to %1$@"; "native.clickTheEditorGutterToAddABreakpoint" = "Click the editor gutter to add a breakpoint"; "native.clickTheFoldedOptionsToExpandRightClickForCommandDetails" = "Click the folded options to expand; right-click for command details."; @@ -6090,6 +6121,7 @@ "native.evaluateExpression" = "Evaluate expression"; "native.evaluateExpressionWhilePaused" = "Evaluate expression while paused"; "native.evaluating" = "Evaluating…"; +"native.everySavedVersionOfThisProjectSFilesIsDeletedOpenFilesAreNotChanged" = "Every saved version of this project's files is deleted. Open files are not changed."; "native.example" = "Example"; "native.exception" = "Exception"; "native.exceptionBreakpoints" = "Exception Breakpoints"; @@ -6288,9 +6320,11 @@ "native.gitExitedWithCodeD" = "Git exited with code %1$d"; "native.gitFetchRemoveStaleBranches" = "Git Fetch: remove stale branches"; "native.gitFetchRemoveStaleTags" = "Git Fetch: remove stale tags"; +"native.gitIgnored" = "Git: ignored"; "native.gitIsPreparingTheSequencer" = "Git is preparing the sequencer."; "native.gitIsReady" = "Git is ready"; "native.gitNotFound" = "Git not found"; +"native.gitNotIgnored" = "Git: not ignored"; "native.gitOperation" = "Git operation"; "native.gitOperationFinished" = "Git operation finished"; "native.gitPhase" = "Git phase: %1$@"; @@ -6355,6 +6389,7 @@ "native.header" = "Header"; "native.headers" = "Headers"; "native.heapMemory" = "Heap Memory"; +"native.hiddenInTheProjectTreeNotRecorded" = "Hidden in the project tree, not recorded"; "native.hiddenPaths" = "Hidden paths"; "native.hideAPIKey" = "Hide API key"; "native.hideBranches" = "Hide Branches"; @@ -6426,6 +6461,8 @@ "native.ifResetUse" = "If reset, use"; "native.ignoreWhitespace" = "Ignore whitespace"; "native.ignored" = "Ignored"; +"native.ignoredByRegularExpressionLineLld" = "Ignored by regular expression line %1$lld"; +"native.ignoredByWildcardRule" = "Ignored by wildcard rule %1$@"; "native.ignoredEntriesLld" = "Ignored entries: %1$lld"; "native.imageZoom" = "Image zoom"; "native.imagesCanOnlyBePastedIntoMarkdownDocuments" = "Images can only be pasted into Markdown documents"; @@ -6721,11 +6758,13 @@ "native.license" = "License"; "native.lifecycle" = "Lifecycle"; "native.light" = "Light"; +"native.limitPerProject" = "Limit per project"; "native.line" = "Line"; "native.lineBreakpointActions" = "Line breakpoint actions"; "native.lineBreakpoints" = "Line Breakpoints"; "native.lineColumn" = "[Line] [:column]:"; "native.lineLld" = "%1$@, Line %2$lld, %3$@"; +"native.lineLldIsNotAValidRegularExpressionAndIsSkipped" = "Line %1$lld is not a valid regular expression and is skipped."; "native.linear" = "Linear"; "native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts" = "Lines and columns start at 1, and a range must not end before it starts."; "native.linesKeptForScrollingBackInNewTerminalSessions" = "Lines kept for scrolling back in new terminal sessions."; @@ -6883,7 +6922,11 @@ "native.localEditsToTheseFilesAreLost" = "Local edits to these files are lost."; "native.localHistory" = "Local History"; "native.localHistory.variant2" = "Local History…"; +"native.localHistory.variant3" = "Local history"; +"native.localHistoryFormat" = "Local history: %1$@"; "native.localHistoryIsAvailableForTextFiles" = "Local History is available for text files"; +"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul16320b202b" = "Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`."; +"native.localHistoryLimitPerProject" = "Local history limit per project"; "native.localHistoryWorkIsStillActive" = "Local history work is still active."; "native.localJDKHomeOptional" = "Local JDK Home (optional)"; "native.localPackage" = "Local Package"; @@ -7348,6 +7391,7 @@ "native.oneFile" = "One file: %1$@"; "native.oneNAMEValueEntryPerLine" = "One NAME=value entry per line"; "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace" = "One path per line. Relative paths are resolved from the workspace."; +"native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99" = "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them."; "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a" = "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply."; "native.onePatternPerLineForExampleOrgMockito" = "One pattern per line, for example org.mockito.* or com.example.generated.*"; "native.onePatternPerLineLogCacheOnlyAffectsThisSearchEverywhereWindow" = "One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window."; @@ -7387,6 +7431,7 @@ "native.openAProjectToConfigureMCPAccessForAgents" = "Open a project to configure MCP access for agents."; "native.openAProjectToLetConnectedAgentsInspectAndControlIt" = "Open a project to let connected agents inspect and control it."; "native.openAProjectToManageItsSkills" = "Open a project to manage its skills."; +"native.openAProjectToSeeHowMuchSpaceItsHistoryUses" = "Open a project to see how much space its history uses."; "native.openAProjectToSeeItsLanguageService" = "Open a project to see its language service."; "native.openAProjectToUseGit" = "Open a project to use Git"; "native.openASourceFileBeforeRunningCurrentFile" = "Open a source file before running Current File."; @@ -7754,6 +7799,7 @@ "native.reconnectToApplyLimits" = "Reconnect the Agent to apply these limits."; "native.reconnecting" = "Reconnecting…"; "native.reconnectingDD" = "Reconnecting %1$d/%2$d…"; +"native.recorded" = "Recorded"; "native.recoveryReference" = "Recovery reference: %1$@"; "native.referenceChangesCouldNotBeInspected" = "Reference changes could not be inspected"; "native.referenceListTruncatedTotalUpdatedLldDeletedLld" = "Reference list truncated; total updated: %1$lld, deleted: %2$lld"; @@ -7786,6 +7832,8 @@ "native.registration" = "Registration"; "native.registryWebsite" = "Registry website"; "native.regularExpression" = "Regular Expression"; +"native.regularExpression.variant2" = "Regular expression"; +"native.regularExpressionIgnoreRules" = "Regular expression ignore rules"; "native.releaseDate" = "Release Date"; "native.releaseNote" = "Release note"; "native.releaseNotes" = "Release notes"; @@ -8545,6 +8593,7 @@ "native.test" = "Test"; "native.testConnection" = "Test Connection"; "native.testJava" = "test Java"; +"native.testPath" = "Test path"; "native.testProcessTreeResidentMemory" = "Test process tree · Resident memory"; "native.testResources" = "test resources"; "native.testRunTimedOutAfterSeconds" = "%1$@ test run timed out after %2$@ seconds."; @@ -9022,6 +9071,7 @@ "native.turningThisOffStopsEveryAgentProcess" = "Turning this off stops every Agent process."; "native.turns" = "Turns"; "native.type" = "Type"; +"native.typeAPathInsideTheProjectToCheckItAgainstTheRulesAboveAsYouEditThemWhetherLocalH0f31e93916" = "Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history."; "native.typeChanged" = "Type changed"; "native.typeToSeeCommands" = "Type / to see commands"; "native.typeToSeeOptions" = "Type / to see options"; @@ -9176,6 +9226,7 @@ "native.useYourLocalChatGPTAccountNoAPIKeyOrURLIsNeeded" = "Use your local ChatGPT account. No API key or URL is needed."; "native.used" = "%1$@ · %2$@ used"; "native.used.variant2" = "Used %1$@"; +"native.used.variant3" = "Used"; "native.usedBy" = "Used by: %1$@"; "native.usedForNewTerminalSessions" = "Used for new terminal sessions."; "native.usedForOrdinaryFetchInEveryProjectOneTimeChoicesCanOverrideTheseDefaults" = "Used for ordinary Fetch in every project. One-time choices can override these defaults."; @@ -9274,6 +9325,7 @@ "native.whenQuittingLithe" = "When quitting Lithe"; "native.whenShouldThisSkillBeUsed" = "When should this skill be used?"; "native.whenSubmittingAPrompt" = "When submitting a prompt"; +"native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst" = "When the limit is reached, the oldest versions are removed first."; "native.whenToRun" = "When to run"; "native.wherePushGoesWhenYouDoNotChooseATargetExplicitly" = "Where Push goes when you do not choose a target explicitly."; "native.whereShouldThisApply" = "Where should this apply?"; @@ -9281,6 +9333,8 @@ "native.whereWouldYouLikeToOpenTheProject" = "Where would you like to open the project ‘%1$@’?"; "native.whitespaceComparison" = "Whitespace comparison"; "native.wholeWords" = "Whole Words"; +"native.wildcard" = "Wildcard"; +"native.wildcardIgnoreRules" = "Wildcard ignore rules"; "native.window" = "Window"; "native.wip" = "WIP"; "native.withdrawn" = "Withdrawn"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 7ed9652bd2..ea4dcd246a 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -688,6 +688,7 @@ "Clear Git console" = "清空 Git 控制台"; "Clear Logs" = "清空日志"; "Clear Override" = "清除覆盖"; +"Clear Project History…" = "清空项目历史…"; "Clear branch filter" = "清除分支筛选"; "Clear build output" = "清除构建输出"; "Clear collected logs?" = "清空已收集的日志?"; @@ -702,7 +703,9 @@ "Clear test output" = "清除测试输出"; "Clear the current project's Java index and rebuild it on next use" = "清除当前项目的 Java 索引,并在下次使用时重建"; "Clear the override and inherit" = "清除覆盖并恢复继承"; +"Clear this project's local history?" = "清空此项目的本地历史?"; "Clear user filter" = "清除用户筛选"; +"Clearing…" = "正在清空…"; "Click the editor gutter to add a breakpoint" = "点击编辑器左侧行号区域添加断点"; "Click the folded options to expand; right-click for command details." = "点击折叠参数可展开;右键查看命令详情。"; "Client: %@" = "客户端:%@"; @@ -971,6 +974,7 @@ "Copy worktree path" = "复制工作树路径"; "Could not check for updates" = "无法检查更新"; "Could not check for updates." = "无法检查更新。"; +"Could not clear this project's local history. Check storage permissions and try again." = "无法清空此项目的本地历史。请检查存储目录权限后重试。"; "Could not compare the local adapter with the verified version." = "无法比较本地适配器与已核验版本。"; "Could not confirm" = "无法确认"; "Could not connect to the Debug Adapter: %@" = "无法连接调试适配器:%@"; @@ -1420,6 +1424,7 @@ "Evaluate expression" = "计算表达式"; "Evaluate expression while paused" = "暂停时计算表达式"; "Evaluating…" = "正在计算…"; +"Every saved version of this project's files is deleted. Open files are not changed." = "此项目文件的所有已保存版本都会被删除,已打开的文件不受影响。"; "Example" = "示例"; "Exception" = "异常"; "Exception Breakpoints" = "异常断点"; @@ -1639,6 +1644,8 @@ "Git version" = "Git 版本"; "Git was not found" = "未找到 Git"; "Git work is still active." = "Git 任务仍在进行。"; +"Git: ignored" = "Git:已忽略"; +"Git: not ignored" = "Git:未忽略"; "GitHub authorization failed:" = "GitHub 授权失败:"; "GitHub authorization failed: %@" = "GitHub 授权失败:%@"; "GitHub authorization was cancelled." = "GitHub 授权已取消。"; @@ -1698,6 +1705,7 @@ "Headers" = "请求头"; "Heap Memory" = "堆内存"; "Hexadecimal" = "十六进制"; +"Hidden in the project tree, not recorded" = "已在项目树中隐藏,不记录"; "Hidden paths" = "隐藏路径"; "Hide %@ tool window" = "隐藏 %@ 工具窗口"; "Hide API key" = "隐藏密钥"; @@ -1763,6 +1771,8 @@ "If: %@" = "条件:%@"; "Ignore whitespace" = "忽略空白"; "Ignored" = "已忽略"; +"Ignored by regular expression line %lld" = "被正则表达式第 %lld 行忽略"; +"Ignored by wildcard rule %@" = "被通配符规则 %@ 忽略"; "Ignored entries: %lld" = "已忽略条目:%lld"; "Image zoom" = "图片缩放"; "Images can only be pasted into Markdown documents" = "图片只能粘贴到 Markdown 文档中"; @@ -1987,7 +1997,9 @@ "License" = "许可证"; "Lifecycle" = "生命周期"; "Light" = "浅色"; +"Limit per project" = "每个项目上限"; "Line" = "行"; +"Line %lld is not a valid regular expression and is skipped." = "第 %lld 行不是有效的正则表达式,已跳过。"; "Line Breakpoints" = "行断点"; "Line breakpoint actions" = "行断点操作"; "Line numbers" = "行号"; @@ -2099,7 +2111,11 @@ "Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation." = "本机配置跟随 CLI 文件。自定义供应商保存在 Lithe 中,不修改本机文件。切换会重启当前项目的 Agent 连接,可重新打开历史会话继续对话。"; "Local development package" = "本地开发包"; "Local edits to these files are lost." = "这些文件的本地修改将会丢失。"; +"Local history" = "本地历史"; +"Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`." = "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。列表开头是内置的依赖、缓存和构建输出文件夹,例如 `**/node_modules/`;删掉一行即可重新记录该文件夹。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。"; +"Local history limit per project" = "每个项目的本地历史上限"; "Local history work is still active." = "本地历史任务仍在进行。"; +"Local history: %@" = "本地历史:%@"; "Location" = "位置"; "Lock Worktree" = "锁定工作树"; "Locked" = "已锁定"; @@ -2554,6 +2570,7 @@ "One file: %@" = "单个文件:%@"; "One path per line. Relative paths are resolved from the workspace." = "每行一个路径;相对路径以当前工作区为基准。"; "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply." = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/*` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n下方的内置规则始终生效。"; +"One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them." = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/`、`/*` 或 `/**` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级。不带结尾斜杠的规则只隐藏文件:`.lithe` 隐藏名为 .lithe 的文件,而不是文件夹。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/**` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n列表开头是内置规则。删掉一行即可重新显示这些文件,再输入回来就会重新隐藏。"; "One pattern per line, for example org.mockito.* or com.example.generated.*" = "每行一个模式,例如 org.mockito.* 或 com.example.generated.*"; "One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window." = "每行一个表达式:`*.log`、`**/.*/`、`.*`、`cache/`。仅影响当前双 Shift 搜索窗口。"; "One sentence focused on the main change" = "聚焦主要更改的一句话"; @@ -2616,6 +2633,7 @@ "Open a project to configure its run configurations." = "打开项目后即可编辑运行配置。"; "Open a project to let connected agents inspect and control it." = "打开项目后,已连接的 Agent 才能检查和控制它。"; "Open a project to manage its skills." = "请先打开项目,再管理项目技能。"; +"Open a project to see how much space its history uses." = "打开项目后可查看其历史占用的空间。"; "Open a project to see its language service." = "打开项目后可查看其语言服务。"; "Open a project to use Git" = "请打开项目以使用 Git"; "Open a source file before running Current File." = "运行“当前文件”前请先打开一个源文件。"; @@ -2969,6 +2987,7 @@ "Reconnect this Agent to refresh its model list." = "重新连接此 Agent 即可刷新模型列表。"; "Reconnecting %d/%d…" = "正在重连 %d/%d…"; "Reconnecting…" = "正在重连…"; +"Recorded" = "会记录"; "Recovery reference: %@" = "恢复引用:%@"; "Reference changes could not be inspected" = "无法读取引用变更"; "Reference list truncated; total updated: %lld, deleted: %lld" = "引用列表已截断;更新总数:%lld,删除总数:%lld"; @@ -3003,6 +3022,8 @@ "Registration" = "注册状态"; "Registry website" = "Registry 网站"; "Regular Expression" = "正则表达式"; +"Regular expression" = "正则表达式"; +"Regular expression ignore rules" = "正则表达式忽略规则"; "Reinstall" = "重新安装"; "Relative" = "相对行号"; "Release Date" = "发布日期"; @@ -3799,6 +3820,7 @@ "Test" = "测试"; "Test Connection" = "测试连接"; "Test connection" = "测试连接"; +"Test path" = "测试路径"; "Test process tree · Resident memory" = "测试进程树 · 驻留内存"; "Test the connection to see the host's system, resources, and address." = "测试连接后,这里会显示主机的系统、资源和地址。"; "Testing" = "测试"; @@ -4324,6 +4346,7 @@ "Type" = "类型"; "Type / to see commands" = "输入 / 查看命令"; "Type / to see options" = "输入 / 查看选项"; +"Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history." = "输入项目内的路径,随着你编辑上面的规则实时检查:本地历史是否会记录这个文件,以及 Git 是否忽略它。Git 的忽略规则不影响本地历史。"; "Type changed" = "类型已更改"; "Type your task or / for commands" = "输入任务,或用 / 选择命令"; "Type your task, / for commands or @ for files" = "输入任务,用 / 选择命令,用 @ 添加文件"; @@ -4479,6 +4502,7 @@ "Use this when a repository or Git default needs a one-off adjustment." = "仅在需要临时调整仓库或 Git 默认值时使用。"; "Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "技能名称最多 64 个字符,请使用小写字母、数字和单个连字符。"; "Use your local ChatGPT account. No API key or URL is needed." = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。"; +"Used" = "已用"; "Used %@" = "已使用 %@"; "Used %lld tool" = "调用了 %lld 个工具"; "Used %lld tools" = "调用了 %lld 个工具"; @@ -4567,6 +4591,7 @@ "When quitting Lithe" = "退出 Lithe 时"; "When should this skill be used?" = "什么情况下应使用这个技能?"; "When submitting a prompt" = "提交提示词时"; +"When the limit is reached, the oldest versions are removed first." = "达到上限时,最早的版本会先被删除。"; "When to run" = "何时运行"; "Where Push goes when you do not choose a target explicitly." = "未明确选择目标时,Push 会推送到哪里。"; "Where should this apply?" = "应用范围"; @@ -4574,6 +4599,8 @@ "Where would you like to open the project ‘%@’?" = "你想在哪里打开项目“%@”?"; "Whitespace comparison" = "空白字符比较"; "Whole Words" = "全字匹配"; +"Wildcard" = "通配符"; +"Wildcard ignore rules" = "通配符忽略规则"; "Window" = "窗口"; "Withdrawn" = "已撤回"; "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you." = "不勾选“记住到钥匙串”时每次连接都会询问。记住的密码只在服务器第一次询问时自动填入;如果被拒绝,Lithe 会改为询问你。"; @@ -4765,6 +4792,7 @@ "git.rollback.warning" = "将回滚勾选文件中已暂存和未暂存的更改。Lithe 无法撤销此操作。"; "git.status.refreshFailed" = "无法刷新 Git 状态。请刷新以确认更改结果。"; "git.unifiedView" = "统一视图"; +"history.clearFailed" = "无法清空此项目的本地历史。请检查存储目录权限后重试。"; "https://github.com/owner/repository" = "https://github.com/owner/repository"; "java.launch.error.buildCancelled" = "Java 项目构建在完成前已被取消。"; "java.launch.error.buildFailed" = "Java 语言服务未能完成项目构建,请查看 Java 语言服务器日志了解构建错误。"; @@ -5344,13 +5372,16 @@ "native.clearLogs" = "清空日志"; "native.clearOverride" = "清除覆盖"; "native.clearPathFilter" = "清除路径筛选"; +"native.clearProjectHistory" = "清空项目历史…"; "native.clearRunOutput" = "清除运行输出"; "native.clearSearch" = "清除搜索"; "native.clearSelection" = "清除勾选"; "native.clearTestOutput" = "清除测试输出"; "native.clearTheCurrentProjectSJavaIndexAndRebuildItOnNextUse" = "清除当前项目的 Java 索引,并在下次使用时重建"; "native.clearTheOverrideAndInherit" = "清除覆盖并恢复继承"; +"native.clearThisProjectSLocalHistory" = "清空此项目的本地历史?"; "native.clearUserFilter" = "清除用户筛选"; +"native.clearing" = "正在清空…"; "native.cliUpdatedTo" = "CLI 已更新至 %1$@"; "native.clickTheEditorGutterToAddABreakpoint" = "点击编辑器左侧行号区域添加断点"; "native.clickTheFoldedOptionsToExpandRightClickForCommandDetails" = "点击折叠参数可展开;右键查看命令详情。"; @@ -6090,6 +6121,7 @@ "native.evaluateExpression" = "计算表达式"; "native.evaluateExpressionWhilePaused" = "暂停时计算表达式"; "native.evaluating" = "正在计算…"; +"native.everySavedVersionOfThisProjectSFilesIsDeletedOpenFilesAreNotChanged" = "此项目文件的所有已保存版本都会被删除,已打开的文件不受影响。"; "native.example" = "示例"; "native.exception" = "异常"; "native.exceptionBreakpoints" = "异常断点"; @@ -6288,9 +6320,11 @@ "native.gitExitedWithCodeD" = "Git 已退出,退出码为 %1$d"; "native.gitFetchRemoveStaleBranches" = "Git Fetch:清理失效的远程分支"; "native.gitFetchRemoveStaleTags" = "Git Fetch:清理失效的远程标签"; +"native.gitIgnored" = "Git:已忽略"; "native.gitIsPreparingTheSequencer" = "Git 正在准备变基步骤。"; "native.gitIsReady" = "Git 已就绪"; "native.gitNotFound" = "未找到 Git"; +"native.gitNotIgnored" = "Git:未忽略"; "native.gitOperation" = "Git 操作"; "native.gitOperationFinished" = "Git 操作已完成"; "native.gitPhase" = "Git 阶段:%1$@"; @@ -6355,6 +6389,7 @@ "native.header" = "请求头"; "native.headers" = "请求头"; "native.heapMemory" = "堆内存"; +"native.hiddenInTheProjectTreeNotRecorded" = "已在项目树中隐藏,不记录"; "native.hiddenPaths" = "隐藏路径"; "native.hideAPIKey" = "隐藏密钥"; "native.hideBranches" = "隐藏分支"; @@ -6426,6 +6461,8 @@ "native.ifResetUse" = "重置后使用"; "native.ignoreWhitespace" = "忽略空白"; "native.ignored" = "已忽略"; +"native.ignoredByRegularExpressionLineLld" = "被正则表达式第 %1$lld 行忽略"; +"native.ignoredByWildcardRule" = "被通配符规则 %1$@ 忽略"; "native.ignoredEntriesLld" = "已忽略条目:%1$lld"; "native.imageZoom" = "图片缩放"; "native.imagesCanOnlyBePastedIntoMarkdownDocuments" = "图片只能粘贴到 Markdown 文档中"; @@ -6721,11 +6758,13 @@ "native.license" = "许可证"; "native.lifecycle" = "生命周期"; "native.light" = "浅色"; +"native.limitPerProject" = "每个项目上限"; "native.line" = "行"; "native.lineBreakpointActions" = "行断点操作"; "native.lineBreakpoints" = "行断点"; "native.lineColumn" = "[行] [:列]:"; "native.lineLld" = "%1$@,第 %2$lld 行,%3$@"; +"native.lineLldIsNotAValidRegularExpressionAndIsSkipped" = "第 %1$lld 行不是有效的正则表达式,已跳过。"; "native.linear" = "Linear"; "native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts" = "行号和列号从 1 开始,范围的终点不能早于起点。"; "native.linesKeptForScrollingBackInNewTerminalSessions" = "新建终端会话保留的可回滚行数。"; @@ -6883,7 +6922,11 @@ "native.localEditsToTheseFilesAreLost" = "这些文件的本地修改将会丢失。"; "native.localHistory" = "本地历史"; "native.localHistory.variant2" = "本地历史…"; +"native.localHistory.variant3" = "本地历史"; +"native.localHistoryFormat" = "本地历史:%1$@"; "native.localHistoryIsAvailableForTextFiles" = "本地历史仅适用于文本文件"; +"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul16320b202b" = "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。列表开头是内置的依赖、缓存和构建输出文件夹,例如 `**/node_modules/`;删掉一行即可重新记录该文件夹。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。"; +"native.localHistoryLimitPerProject" = "每个项目的本地历史上限"; "native.localHistoryWorkIsStillActive" = "本地历史任务仍在进行。"; "native.localJDKHomeOptional" = "本地 JDK 主目录(可选)"; "native.localPackage" = "本地插件包"; @@ -7348,6 +7391,7 @@ "native.oneFile" = "单个文件:%1$@"; "native.oneNAMEValueEntryPerLine" = "每行填写一个 NAME=value"; "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace" = "每行一个路径;相对路径以当前工作区为基准。"; +"native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99" = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/`、`/*` 或 `/**` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级。不带结尾斜杠的规则只隐藏文件:`.lithe` 隐藏名为 .lithe 的文件,而不是文件夹。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/**` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n列表开头是内置规则。删掉一行即可重新显示这些文件,再输入回来就会重新隐藏。"; "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a" = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/*` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n下方的内置规则始终生效。"; "native.onePatternPerLineForExampleOrgMockito" = "每行一个模式,例如 org.mockito.* 或 com.example.generated.*"; "native.onePatternPerLineLogCacheOnlyAffectsThisSearchEverywhereWindow" = "每行一个表达式:`*.log`、`**/.*/`、`.*`、`cache/`。仅影响当前双 Shift 搜索窗口。"; @@ -7387,6 +7431,7 @@ "native.openAProjectToConfigureMCPAccessForAgents" = "打开项目后,可在这里配置 Agent 的 MCP 访问权限。"; "native.openAProjectToLetConnectedAgentsInspectAndControlIt" = "打开项目后,已连接的 Agent 才能检查和控制它。"; "native.openAProjectToManageItsSkills" = "请先打开项目,再管理项目技能。"; +"native.openAProjectToSeeHowMuchSpaceItsHistoryUses" = "打开项目后可查看其历史占用的空间。"; "native.openAProjectToSeeItsLanguageService" = "打开项目后可查看其语言服务。"; "native.openAProjectToUseGit" = "请打开项目以使用 Git"; "native.openASourceFileBeforeRunningCurrentFile" = "运行“当前文件”前请先打开一个源文件。"; @@ -7754,6 +7799,7 @@ "native.reconnectToApplyLimits" = "重新连接 Agent 后生效。"; "native.reconnecting" = "正在重连…"; "native.reconnectingDD" = "正在重连 %1$d/%2$d…"; +"native.recorded" = "会记录"; "native.recoveryReference" = "恢复引用:%1$@"; "native.referenceChangesCouldNotBeInspected" = "无法读取引用变更"; "native.referenceListTruncatedTotalUpdatedLldDeletedLld" = "引用列表已截断;更新总数:%1$lld,删除总数:%2$lld"; @@ -7786,6 +7832,8 @@ "native.registration" = "注册状态"; "native.registryWebsite" = "Registry 网站"; "native.regularExpression" = "正则表达式"; +"native.regularExpression.variant2" = "正则表达式"; +"native.regularExpressionIgnoreRules" = "正则表达式忽略规则"; "native.releaseDate" = "发布日期"; "native.releaseNote" = "发布说明"; "native.releaseNotes" = "发行说明"; @@ -8545,6 +8593,7 @@ "native.test" = "测试"; "native.testConnection" = "测试连接"; "native.testJava" = "测试代码"; +"native.testPath" = "测试路径"; "native.testProcessTreeResidentMemory" = "测试进程树 · 驻留内存"; "native.testResources" = "测试资源"; "native.testRunTimedOutAfterSeconds" = "%1$@ 测试运行在 %2$@ 秒后超时。"; @@ -9022,6 +9071,7 @@ "native.turningThisOffStopsEveryAgentProcess" = "关闭后会停止所有 Agent 进程。"; "native.turns" = "轮次"; "native.type" = "类型"; +"native.typeAPathInsideTheProjectToCheckItAgainstTheRulesAboveAsYouEditThemWhetherLocalH0f31e93916" = "输入项目内的路径,随着你编辑上面的规则实时检查:本地历史是否会记录这个文件,以及 Git 是否忽略它。Git 的忽略规则不影响本地历史。"; "native.typeChanged" = "类型已更改"; "native.typeToSeeCommands" = "输入 / 查看命令"; "native.typeToSeeOptions" = "输入 / 查看选项"; @@ -9176,6 +9226,7 @@ "native.useYourLocalChatGPTAccountNoAPIKeyOrURLIsNeeded" = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。"; "native.used" = "%1$@ · 已用 %2$@"; "native.used.variant2" = "已使用 %1$@"; +"native.used.variant3" = "已用"; "native.usedBy" = "使用者:%1$@"; "native.usedForNewTerminalSessions" = "用于新建的终端会话。"; "native.usedForOrdinaryFetchInEveryProjectOneTimeChoicesCanOverrideTheseDefaults" = "用于所有项目中的普通 Fetch;单次操作可以覆盖这些默认值。"; @@ -9274,6 +9325,7 @@ "native.whenQuittingLithe" = "退出 Lithe 时"; "native.whenShouldThisSkillBeUsed" = "什么情况下应使用这个技能?"; "native.whenSubmittingAPrompt" = "提交提示词时"; +"native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst" = "达到上限时,最早的版本会先被删除。"; "native.whenToRun" = "何时运行"; "native.wherePushGoesWhenYouDoNotChooseATargetExplicitly" = "未明确选择目标时,Push 会推送到哪里。"; "native.whereShouldThisApply" = "应用范围"; @@ -9281,6 +9333,8 @@ "native.whereWouldYouLikeToOpenTheProject" = "你想在哪里打开项目“%1$@”?"; "native.whitespaceComparison" = "空白字符比较"; "native.wholeWords" = "全字匹配"; +"native.wildcard" = "通配符"; +"native.wildcardIgnoreRules" = "通配符忽略规则"; "native.window" = "窗口"; "native.wip" = "进行中"; "native.withdrawn" = "已撤回"; diff --git a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift index 3aa544c134..8f08fcd0eb 100644 --- a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift @@ -75,9 +75,10 @@ enum WorkspaceProjectionComposition { refreshGit: { [weak model] in await model?.gitFeatureIfActive?.refreshGitFromMetadataChange() }, - updateHistoryVisibilityRules: { [weak model] rules in - guard let feature = await model?.activateHistoryModule() else { return } - await feature.updateVisibilityRules(rules.localHistoryRules) + updateHistoryVisibilityRules: { [weak model] _ in + // The ignore rules live beside project visibility, so read both from settings. + guard let model, let feature = await model.activateHistoryModule() else { return } + await feature.updateVisibilityRules(model.settings.localHistoryRules) }, onSnapshotLoaded: { [weak model] snapshot, isInitialLoad in guard let model else { return } diff --git a/macos/Sources/Lithe/Application/Features/LocalHistoryStorageSettingsModel.swift b/macos/Sources/Lithe/Application/Features/LocalHistoryStorageSettingsModel.swift new file mode 100644 index 0000000000..aca5bd55a3 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/LocalHistoryStorageSettingsModel.swift @@ -0,0 +1,27 @@ +import Combine +import LitheLocalHistoryModule + +/// Owns storage feedback for the settings page, including failed clears and retries. +@MainActor +final class LocalHistoryStorageSettingsModel: ObservableObject { + @Published private(set) var usage: LocalHistoryUsage? + @Published private(set) var isClearing = false + @Published private(set) var clearFailed = false + + func refreshUsage(using readUsage: @MainActor () async -> LocalHistoryUsage?) async { + usage = await readUsage() + } + + func clear( + using clearHistory: @MainActor () async -> Bool, + readUsage: @MainActor () async -> LocalHistoryUsage? + ) async { + guard !isClearing else { return } + isClearing = true + clearFailed = false + defer { isClearing = false } + let cleared = await clearHistory() + await refreshUsage(using: readUsage) + clearFailed = !cleared + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 77dc9e3aa8..92f3600e7c 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -262,6 +262,46 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let relocated: Bool } + struct HistoryUsagePayload: Decodable, Sendable { + let usedBytes: Int64 + let limitBytes: Int64 + } + + struct HistoryIgnorePreviewPayload: Decodable, Sendable { + struct RegexError: Decodable, Sendable, Equatable { + let index: Int + let message: String + } + + enum Match: Decodable, Sendable, Equatable { + case hidden + case pattern(String) + case regex(index: Int) + + private enum CodingKeys: String, CodingKey { case kind, pattern, index } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .kind) { + case "hidden": self = .hidden + case "pattern": self = .pattern(try container.decode(String.self, forKey: .pattern)) + case "regex": self = .regex(index: try container.decode(Int.self, forKey: .index)) + case let kind: + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown match \(kind)") + } + } + } + + let regexErrors: [RegexError] + let builtInIgnorePatterns: [String] + let matched: Match? + let gitIgnored: Bool? + } + + private struct HistoryClearPayload: Decodable, Sendable { + let cleared: Bool + } + struct MarkdownRenderPayload: Decodable, Sendable { let html: String } @@ -1603,6 +1643,25 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let pruneExpired: Bool let hiddenDirectoryNames: [String] let hiddenFilePatterns: [String] + let ignoreRules: HistoryIgnoreRules + let storageLimitBytes: Int64? + } + + private struct HistoryIgnoreRules: Encodable { + let pathPatterns: [String] + let pathRegexes: [String] + } + + private struct HistoryIgnorePreviewRequest: Encodable { + let path: String? + let ignoreRules: HistoryIgnoreRules + let hiddenFilePatterns: [String] + let workspaceRoot: String? + } + + private struct HistoryStorageRequest: Encodable { + let storageRoot: String + var storageLimitBytes: Int64? } private struct HistoryEntriesRequest: Encodable { @@ -2678,7 +2737,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { content: String?, pruneExpired: Bool, hiddenDirectoryNames: [String], - hiddenFilePatterns: [String] + hiddenFilePatterns: [String], + ignorePatterns: [String] = [], + ignoreRegexes: [String] = [], + storageLimitBytes: Int64? = nil ) -> HistoryEntryPayload? { execute( command: "history.record", @@ -2690,11 +2752,52 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { content: content, pruneExpired: pruneExpired, hiddenDirectoryNames: hiddenDirectoryNames, - hiddenFilePatterns: hiddenFilePatterns + hiddenFilePatterns: hiddenFilePatterns, + ignoreRules: HistoryIgnoreRules( + pathPatterns: ignorePatterns, + pathRegexes: ignoreRegexes + ), + storageLimitBytes: storageLimitBytes + ) + ) + } + + func historyIgnorePreview( + path: String?, + ignorePatterns: [String], + ignoreRegexes: [String], + hiddenFilePatterns: [String], + workspaceURL: URL? = nil + ) -> HistoryIgnorePreviewPayload? { + execute( + command: "history.ignorePreview", + payload: HistoryIgnorePreviewRequest( + path: path, + ignoreRules: HistoryIgnoreRules( + pathPatterns: ignorePatterns, + pathRegexes: ignoreRegexes + ), + hiddenFilePatterns: hiddenFilePatterns, + workspaceRoot: workspaceURL?.standardizedFileURL.path ) ) } + func historyUsage(storageURL: URL, limitBytes: Int64? = nil) -> HistoryUsagePayload? { + execute( + command: "history.usage", + payload: HistoryStorageRequest(storageRoot: storageURL.standardizedFileURL.path, storageLimitBytes: limitBytes) + ) + } + + func historyClear(storageURL: URL) -> Bool { + let response: HistoryClearPayload? = execute( + command: "history.clear", + payload: HistoryStorageRequest(storageRoot: storageURL.standardizedFileURL.path) + ) + return response?.cleared == true + } + func historyEntries( at workspaceURL: URL, storageURL: URL, diff --git a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift index e45669d7b5..411729059e 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift @@ -9,7 +9,10 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { at: workspaceURL, storageURL: storageURL, relativePath: relativePath, reason: reason.rawValue, content: content, pruneExpired: pruneExpired, hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, - hiddenFilePatterns: visibilityRules.hiddenFilePatterns + hiddenFilePatterns: visibilityRules.hiddenFilePatterns, + ignorePatterns: visibilityRules.ignorePatterns, + ignoreRegexes: visibilityRules.ignoreRegexes, + storageLimitBytes: visibilityRules.storageLimitBytes ).map(Self.makePayload) } @@ -24,6 +27,34 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { func content(at storageURL: URL, contentPath: String) -> String? { core.historyContent(storageURL: storageURL, contentPath: contentPath)?.text } func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { core.historyRelocate(storageURL: storageURL, sourcePath: sourcePath, destinationPath: destinationPath) } + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { + core.historyUsage(storageURL: storageURL, limitBytes: limitBytes).map { LocalHistoryUsage(usedBytes: $0.usedBytes, limitBytes: $0.limitBytes) } + } + + func clear(at storageURL: URL) -> Bool { core.historyClear(storageURL: storageURL) } + + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { + guard let value = core.historyIgnorePreview( + path: path, + ignorePatterns: rules.ignorePatterns, + ignoreRegexes: rules.ignoreRegexes, + hiddenFilePatterns: rules.hiddenFilePatterns, + workspaceURL: workspaceURL + ) else { return nil } + let matched: LocalHistoryIgnoreMatch? = switch value.matched { + case nil: nil + case .hidden: .hidden + case .pattern(let pattern): .pattern(pattern) + case .regex(let index): .regex(index: index) + } + return LocalHistoryIgnorePreview( + regexErrors: Dictionary(value.regexErrors.map { ($0.index, $0.message) }, uniquingKeysWith: { first, _ in first }), + builtInIgnorePatterns: value.builtInIgnorePatterns, + matched: matched, + gitIgnored: value.gitIgnored + ) + } + private static func makePayload(_ value: RustCoreBridge.HistoryEntryPayload) -> LocalHistoryEntryPayload { LocalHistoryEntryPayload(id: value.id, timestamp: value.timestamp, relativePath: value.relativePath, reason: value.reason, contentPath: value.contentPath, byteCount: value.byteCount) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift index cdecb531e1..88624584c8 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift @@ -12,6 +12,21 @@ extension AppModel { await historyModuleCoordinator.activate() } + func localHistoryIgnorePreview(path: String?, rules: LocalHistoryVisibilityRules) async -> LocalHistoryIgnorePreview? { + await activateHistoryModule()?.previewIgnoreRules(path: path, rules: rules) + } + + /// Nil when no project is open. + func localHistoryUsage() async -> LocalHistoryUsage? { + guard workspaceURL != nil else { return nil } + return await activateHistoryModule()?.storageUsage() + } + + func clearLocalHistory() async -> Bool { + guard workspaceURL != nil, let feature = await activateHistoryModule() else { return false } + return await feature.clearHistory() + } + func withHistoryModule(_ action: @escaping @MainActor (ProjectHistoryFeatureModel) async -> Void) { historyModuleCoordinator.perform(action) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 0c71664c05..3c342d6ef9 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -164,6 +164,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { private var featureObservationBinder: AppModelObservationBinder? private var fileVisibilityRulesObserverID: UUID? private var searchExclusionsObservation: AnyCancellable? + private var localHistoryIgnoreRulesObservation: AnyCancellable? private var livePluginObservation: AnyCancellable? private var requestProjectOpen: ((URL) -> Void)? private var requestProjectOpenAtPlacement: ((URL, ProjectOpenPlacement) -> Void)? @@ -264,7 +265,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { store: moduleCapabilityStore, workspace: { [weak self] in self?.workspaceURL }, settings: { [weak self] in - self?.settings.fileVisibilityRules.localHistoryRules + self?.settings.localHistoryRules ?? LocalHistoryVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) }, files: { [weak self] in self?.projectFiles ?? [] }, @@ -589,6 +590,12 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { guard let self else { return } self.workspaceFeature.updateVisibilityRules(self.settings.fileVisibilityRules) } + localHistoryIgnoreRulesObservation = settings.localHistoryIgnoreRulesChanges.sink { [weak self] in + // An inactive module reads the rules when it opens the workspace. + guard let self, let feature = self.projectHistoryFeatureIfActive else { return } + let rules = self.settings.localHistoryRules + Task { await feature.updateVisibilityRules(rules) } + } searchExclusionsObservation = settings.searchExclusionsChanges.sink { [weak self] in guard let self, let root = self.workspaceURL else { return } self.searchFeatureIfActive?.invalidateIndex(at: root, visibilityRules: self.settings.searchEverywhereVisibilityRules.searchRules) @@ -732,6 +739,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { self.fileVisibilityRulesObserverID = nil } searchExclusionsObservation = nil + localHistoryIgnoreRulesObservation = nil livePluginObservation = nil await shutdownModuleRuntime() moduleSessionCoordinator?.stopObserving() diff --git a/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift b/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift index 1b32deffcf..5ce81c2d91 100644 --- a/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift +++ b/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift @@ -26,3 +26,17 @@ extension FileVisibilityRules { ) } } + +extension AppSettings { + /// Project visibility plus the rules that only keep files out of local history. + var localHistoryRules: LocalHistoryVisibilityRules { + let visibility = fileVisibilityRules + return LocalHistoryVisibilityRules( + hiddenDirectoryNames: visibility.hiddenDirectoryNames, + hiddenFilePatterns: visibility.hiddenFilePatterns, + ignorePatterns: localHistoryIgnorePatterns, + ignoreRegexes: localHistoryIgnoreRegexes, + storageLimitBytes: Int64(localHistoryStorageLimitMegabytes) * 1024 * 1024 + ) + } +} diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index bde33e35e0..deb4ca38e9 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -49,6 +49,9 @@ final class AppSettings: ObservableObject { static let legacyHiddenFilePatterns = "settings.hiddenFilePatterns" static let searchExcludedPathPatterns = "settings.searchExcludedPathPatterns" static let searchExclusionsEnabled = "settings.searchExclusionsEnabled" + static let localHistoryIgnorePatterns = "settings.localHistoryIgnorePatterns" + static let localHistoryIgnoreRegexes = "settings.localHistoryIgnoreRegexes" + static let localHistoryStorageLimitMegabytes = "settings.localHistoryStorageLimitMegabytes" static let gitExecutable = "settings.gitExecutable" static let gitUseCredentialHelper = "settings.gitUseCredentialHelper" static let gitFetchOptions = "settings.gitFetchOptions" @@ -194,6 +197,40 @@ final class AppSettings: ObservableObject { if oldValue != searchExclusionsEnabled { searchExclusionsDidChange.send() } } } + /// Kept out of local history only; the project tree still shows these files. + /// Holds the user's own globs plus `!**/name/` for removed built-in folder + /// rules, so built-ins added later still apply. + @Published var localHistoryIgnorePatterns: [String] { + didSet { + defaults.set(localHistoryIgnorePatterns, forKey: Key.localHistoryIgnorePatterns) + if oldValue != localHistoryIgnorePatterns { localHistoryIgnoreRulesDidChange.send() } + } + } + @Published var localHistoryIgnoreRegexes: [String] { + didSet { + defaults.set(localHistoryIgnoreRegexes, forKey: Key.localHistoryIgnoreRegexes) + if oldValue != localHistoryIgnoreRegexes { localHistoryIgnoreRulesDidChange.send() } + } + } + static let defaultLocalHistoryStorageLimitMegabytes = 100 + static let localHistoryStorageLimitRange = 50...5000 + /// Accept a whole nonnegative number, optionally followed by the displayed unit. + nonisolated static func parseLocalHistoryStorageLimitMegabytes(_ input: String) -> Int? { + var number = input.trimmingCharacters(in: .whitespacesAndNewlines) + if number.uppercased().hasSuffix("MB") { + number = String(number.dropLast(2)).trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) }) else { return nil } + return Int(number) + } + + /// Per-project local history cap in MB (1 MB = 1024 × 1024 bytes). + @Published var localHistoryStorageLimitMegabytes: Int { + didSet { + defaults.set(localHistoryStorageLimitMegabytes, forKey: Key.localHistoryStorageLimitMegabytes) + if oldValue != localHistoryStorageLimitMegabytes { localHistoryIgnoreRulesDidChange.send() } + } + } let gitExecutionPreferences = GitExecutionPreferences() @Published var gitExecutable: String { didSet { defaults.set(gitExecutable, forKey: Key.gitExecutable); updateGitExecutionPreferences() } @@ -457,6 +494,11 @@ final class AppSettings: ObservableObject { searchExcludedPathPatterns = defaults.stringArray(forKey: Key.searchExcludedPathPatterns) ?? [] // Existing saved exclusions stay active when upgrading from the inline editor. searchExclusionsEnabled = defaults.object(forKey: Key.searchExclusionsEnabled) as? Bool ?? true + localHistoryIgnorePatterns = defaults.stringArray(forKey: Key.localHistoryIgnorePatterns) ?? [] + localHistoryIgnoreRegexes = defaults.stringArray(forKey: Key.localHistoryIgnoreRegexes) ?? [] + localHistoryStorageLimitMegabytes = (defaults.object(forKey: Key.localHistoryStorageLimitMegabytes) as? Int) + .map { min(max($0, Self.localHistoryStorageLimitRange.lowerBound), Self.localHistoryStorageLimitRange.upperBound) } + ?? Self.defaultLocalHistoryStorageLimitMegabytes gitExecutable = defaults.string(forKey: Key.gitExecutable) ?? "" gitUseCredentialHelper = defaults.object(forKey: Key.gitUseCredentialHelper) as? Bool ?? true var savedFetchOptions = defaults.data(forKey: Key.gitFetchOptions) @@ -609,6 +651,8 @@ final class AppSettings: ObservableObject { private let searchExclusionsDidChange = PassthroughSubject() var searchExclusionsChanges: AnyPublisher { searchExclusionsDidChange.eraseToAnyPublisher() } + private let localHistoryIgnoreRulesDidChange = PassthroughSubject() + var localHistoryIgnoreRulesChanges: AnyPublisher { localHistoryIgnoreRulesDidChange.eraseToAnyPublisher() } /// Search Everywhere globs never change other search clients or project visibility. var searchEverywhereVisibilityRules: FileVisibilityRules { @@ -664,6 +708,9 @@ final class AppSettings: ObservableObject { hiddenPathPatterns = [] searchExcludedPathPatterns = [] searchExclusionsEnabled = true + localHistoryIgnorePatterns = [] + localHistoryIgnoreRegexes = [] + localHistoryStorageLimitMegabytes = Self.defaultLocalHistoryStorageLimitMegabytes gitFetchOptions = GitFetchOptions() gitExecutable = "" gitUseCredentialHelper = true diff --git a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift index 34f9bba877..23e5ebe6ab 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift @@ -181,8 +181,13 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { path.hasPrefix(rootPrefix) ? String(path.dropFirst(rootPrefix.count)) : "" } // One Core call per batch; native loss flags above remain unconditional. + // The `!` entries keep events of built-in folders the user shows again. let filtered: Result<[String], RustCoreBridge.CoreCallError> = core.executeResult( - command: "workspace.filterChanges", payload: relativePaths + command: "workspace.filterChanges", + payload: WorkspaceChangeFilterPayload( + paths: relativePaths, + hiddenFilePatterns: visibilityRules.hiddenFilePatterns.filter { $0.hasPrefix("!") } + ) ) switch filtered { case .success(let paths): @@ -200,3 +205,8 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { stop() } } + +private struct WorkspaceChangeFilterPayload: Encodable { + let paths: [String] + let hiddenFilePatterns: [String] +} diff --git a/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift new file mode 100644 index 0000000000..9f9cf1f33e --- /dev/null +++ b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift @@ -0,0 +1,275 @@ +import LitheCoreContracts +import LitheLocalHistoryModule +import LitheModuleAPI +import SwiftUI + +/// The task identity and the operation use the same effective preview inputs. +struct LocalHistoryIgnorePreviewInput: Hashable { + let path: String + let rules: LocalHistoryVisibilityRules +} + +/// Local history storage and ignore rules. Rules are edited as drafts and only +/// saved by Apply; the test path is checked against the drafts as they change. +struct LocalHistorySettingsControl: View { + private enum RuleKind: Hashable { case wildcard, regex } + + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } + @ObservedObject var settings: AppSettings + @State private var kind = RuleKind.wildcard + @State private var patternsDraft = "" + @State private var regexesDraft = "" + /// Rust Core owns the built-in wildcard rules; they arrive with the first preview. + @State private var builtInPatterns: [String]? + @State private var testPath = "" + @State private var preview: LocalHistoryIgnorePreview? + @StateObject private var storage = LocalHistoryStorageSettingsModel() + @State private var isConfirmingClear = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + storageRow + if storage.clearFailed { + HStack(spacing: 6) { + Label("Could not clear this project's local history. Check storage permissions and try again.", + systemImage: "exclamationmark.triangle") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + Button("Retry") { isConfirmingClear = true } + .buttonStyle(LitheActionButtonStyle()) + .disabled(storage.isClearing) + } + } + LitheSettingsSegmentedControl(selection: $kind, options: [.wildcard, .regex], width: 240) { + switch $0 { + case .wildcard: "Wildcard" + case .regex: "Regular expression" + } + } + editor + regexErrors + HStack(spacing: 6) { + Text("Test path") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + LitheInfoTip("Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history.") + LitheTextField(verbatim: "src/generated/Api.java", text: $testPath) + .frame(maxWidth: 260) + .accessibilityLabel(Text("Test path")) + testResult + } + HStack(spacing: 6) { + Spacer(minLength: 8) + Button("Apply") { apply() } + .buttonStyle(LitheActionButtonStyle()) + .disabled(!hasChanges) + } + } + .onAppear(perform: syncDrafts) + .onChange(of: settings.localHistoryIgnorePatterns) { _ in syncDrafts() } + .onChange(of: settings.localHistoryIgnoreRegexes) { _ in syncDrafts() } + .task { await storage.refreshUsage { await model.localHistoryUsage() } } + .task(id: previewKey) { + let input = previewKey + // Debounce typing; a newer draft cancels this task. + try? await Task.sleep(nanoseconds: 200_000_000) + guard !Task.isCancelled else { return } + let result = await model.localHistoryIgnorePreview( + path: input.path, + rules: input.rules + ) + guard !Task.isCancelled else { return } + preview = result + if builtInPatterns == nil, let builtIns = result?.builtInIgnorePatterns { + builtInPatterns = builtIns + syncDrafts() + } + } + .alert("Clear this project's local history?", isPresented: $isConfirmingClear) { + Button("Clear", role: .destructive) { clear() } + Button("Cancel", role: .cancel) {} + } message: { + Text("Every saved version of this project's files is deleted. Open files are not changed.") + } + } + + // MARK: Storage + + @ViewBuilder + private var storageRow: some View { + HStack(spacing: 6) { + if let usage = storage.usage { + Text("Used") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + LitheSettingsPill(verbatim: Self.bytes(usage.usedBytes), kind: .value) + Text(verbatim: "/") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + limitStepper + Spacer(minLength: 8) + Button { isConfirmingClear = true } label: { + storage.isClearing ? Text("Clearing…") : Text("Clear Project History…") + } + .buttonStyle(LitheActionButtonStyle()) + .disabled(storage.isClearing || usage.usedBytes == 0) + } else { + Text("Limit per project") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + limitStepper + Spacer(minLength: 8) + Text("Open a project to see how much space its history uses.") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.tertiaryText) + } + } + } + + /// Applies at once; older versions over a lower limit are removed at the next save. + private var limitStepper: some View { + LitheSettingsStepper( + value: $settings.localHistoryStorageLimitMegabytes, + in: AppSettings.localHistoryStorageLimitRange, + step: 50, + width: 126, + accessibilityLabel: "Local history limit per project", + title: { "\($0) MB" }, + parse: AppSettings.parseLocalHistoryStorageLimitMegabytes + ) + .help(Text("When the limit is reached, the oldest versions are removed first.")) + } + + private func clear() { + Task { + await storage.clear( + using: { await model.clearLocalHistory() }, + readUsage: { await model.localHistoryUsage() } + ) + } + } + + // MARK: Rules + + @ViewBuilder + private var editor: some View { + switch kind { + case .wildcard: + LitheTextEditor(text: $patternsDraft, height: 120) + .accessibilityLabel(Text("Wildcard ignore rules")) + case .regex: + LitheTextEditor(text: $regexesDraft, height: 120) + .accessibilityLabel(Text("Regular expression ignore rules")) + } + } + + @ViewBuilder + private var regexErrors: some View { + if kind == .regex, let errors = preview?.regexErrors, !errors.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(errors.keys.sorted(), id: \.self) { index in + Label(String(format: L10n.string("Line %lld is not a valid regular expression and is skipped."), index + 1), + systemImage: "exclamationmark.triangle") + .help(Text(verbatim: errors[index] ?? "")) + } + } + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + } + } + + @ViewBuilder + private var testResult: some View { + if !testPath.trimmingCharacters(in: .whitespaces).isEmpty, let preview { + let recorded = preview.matched == nil + VStack(alignment: .leading, spacing: 2) { + Label(String(format: L10n.string("Local history: %@"), resultText(preview.matched)), + systemImage: recorded ? "checkmark.circle" : "nosign") + .foregroundStyle(recorded ? LitheTheme.success : LitheTheme.warning) + // Informational only: local history does not follow Git ignore rules. + if let gitIgnored = preview.gitIgnored { + Label(gitIgnored ? L10n.string("Git: ignored") : L10n.string("Git: not ignored"), + systemImage: "arrow.triangle.branch") + .foregroundStyle(LitheTheme.secondaryText) + } + } + .font(LitheTheme.smallFont) + .lineLimit(2) + } + } + + private func resultText(_ match: LocalHistoryIgnoreMatch?) -> String { + switch match { + case nil: + L10n.string("Recorded") + case .hidden: + L10n.string("Hidden in the project tree, not recorded") + case .pattern(let pattern): + String(format: L10n.string("Ignored by wildcard rule %@"), pattern) + case .regex(let index): + String(format: L10n.string("Ignored by regular expression line %lld"), index + 1) + } + } + + private var previewKey: LocalHistoryIgnorePreviewInput { + LocalHistoryIgnorePreviewInput( + path: testPath.trimmingCharacters(in: .whitespaces), rules: draftRules + ) + } + + /// The drafts as rules, with the saved hidden paths so a shown built-in previews as visible. + private var draftRules: LocalHistoryVisibilityRules { + LocalHistoryVisibilityRules( + hiddenDirectoryNames: [], + hiddenFilePatterns: settings.fileVisibilityRules.hiddenFilePatterns, + ignorePatterns: storedPatterns, + ignoreRegexes: lines(regexesDraft) + ) + } + + /// Saved form of the wildcard draft: the user's lines plus `!rule` for each + /// deleted built-in. The saved value until the built-ins are known. + private var storedPatterns: [String] { + guard let builtInPatterns else { return settings.localHistoryIgnorePatterns } + return BuiltInRuleList.stored(lines: entries(patternsDraft), builtIns: builtInPatterns) + } + + private var hasChanges: Bool { + storedPatterns != settings.localHistoryIgnorePatterns + || entries(regexesDraft) != settings.localHistoryIgnoreRegexes + } + + private func syncDrafts() { + // Until the built-ins arrive the editor stays empty rather than showing a partial list. + if let builtInPatterns { + patternsDraft = BuiltInRuleList.displayed( + stored: settings.localHistoryIgnorePatterns, builtIns: builtInPatterns + ).joined(separator: "\n") + } + regexesDraft = settings.localHistoryIgnoreRegexes.joined(separator: "\n") + } + + private func apply() { + settings.localHistoryIgnorePatterns = storedPatterns + settings.localHistoryIgnoreRegexes = entries(regexesDraft) + // Echo what was stored, without the blank lines. + syncDrafts() + } + + /// Raw lines keep their numbers, so errors and matches point at the line the user sees. + private func lines(_ text: String) -> [String] { + text.components(separatedBy: "\n") + } + + private func entries(_ text: String) -> [String] { + lines(text) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + private static func bytes(_ count: Int64) -> String { + // The limit is 100 MiB; binary units show it as 100 MB rather than 104.9 MB. + ByteCountFormatter.string(fromByteCount: count, countStyle: .binary) + } +} diff --git a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift index eff7e4c78d..32ea4e49a9 100644 --- a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift +++ b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift @@ -16,7 +16,7 @@ enum SettingsSearchVocabulary { static func terms(for category: SettingsCategory) -> [String] { switch category { case .general: - ["General", "Appearance", "Color theme", "Appearance mode", "Language", "Projects", "Files", "Version control", "Hidden paths"] + ["General", "Appearance", "Color theme", "Appearance mode", "Language", "Projects", "Files", "Version control", "Hidden paths", "Local history", "Wildcard", "Regular expression", "Limit per project", "Clear Project History…"] case .editor: ["Editor", "Display", "Editor tabs", "tabs", "Font", "Font size", "Search fonts", "File tree row height", "Show minimap", "Minimap", "Indentation", "Tab width", "Line spacing", "Font ligatures", "Syntax highlighting", "Editor color scheme", "Italic comments", "Colorize bracket pairs", "Line numbers", "Render whitespace", "Soft wrap", "Show indent guides", "Highlight current line", "Sticky scroll", "Smooth scrolling", "Scroll beyond last line", "Cursor style", "Cursor blinking"] case .keymap: diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 11d2a92692..2dd7b00b86 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -549,31 +549,23 @@ struct SettingsView: View { } } - group("Hidden paths", info: "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply.") { + group("Hidden paths", info: "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.") { hiddenPathsEditor } + + group("Local history", info: "Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.") { + LocalHistorySettingsControl(settings: settings) + } } } - /// One pattern list with the built-in patterns echoed beneath it, so the - /// effective rules are visible without being editable. + /// One pattern list that starts with the built-in patterns. Deleting a + /// built-in line turns that rule off; typing it again turns it back on. private var hiddenPathsEditor: some View { - let builtIn = FileVisibilityRules.builtInPatterns - let shown = builtIn.prefix(3) - return VStack(alignment: .leading, spacing: 6) { - LitheTextEditor(text: $viewState.hiddenPathPatternsDraft, height: 72) + VStack(alignment: .leading, spacing: 6) { + LitheTextEditor(text: $viewState.hiddenPathPatternsDraft, height: 120) .accessibilityLabel(Text("Hidden paths")) HStack(spacing: 6) { - HStack(spacing: 6) { - Text("Built-in") - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.tertiaryText) - ForEach(shown, id: \.self) { LitheSettingsPill(verbatim: $0, kind: .value) } - if builtIn.count > shown.count { - LitheSettingsPill(verbatim: "+\(builtIn.count - shown.count)") - } - } - .help(Text(verbatim: builtIn.joined(separator: " "))) Spacer(minLength: 8) Button("Apply") { applyVisibilityDrafts() } .buttonStyle(LitheActionButtonStyle()) @@ -1755,11 +1747,17 @@ struct SettingsView: View { } private func syncVisibilityDrafts() { - viewState.hiddenPathPatternsDraft = settings.hiddenPathPatterns.joined(separator: "\n") + viewState.hiddenPathPatternsDraft = BuiltInRuleList.displayed( + stored: settings.hiddenPathPatterns, + builtIns: FileVisibilityRules.builtInPatterns + ).joined(separator: "\n") } private func applyVisibilityDrafts() { - settings.hiddenPathPatterns = entries(from: viewState.hiddenPathPatternsDraft) + settings.hiddenPathPatterns = BuiltInRuleList.stored( + lines: entries(from: viewState.hiddenPathPatternsDraft), + builtIns: FileVisibilityRules.builtInPatterns + ) } private func entries(from text: String) -> [String] { diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index faf93607b0..1ecf4f4f4a 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -1062,6 +1062,10 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V private let width: CGFloat private let accessibilityLabel: Text private let title: (Value) -> String + /// Turns typed text into a value; nil keeps the value read-only. + private let parse: ((String) -> Value?)? + @State private var draft = "" + @FocusState private var isEditing: Bool init( value: Binding, @@ -1069,7 +1073,8 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V step: Value.Stride, width: CGFloat, accessibilityLabel: LocalizedStringKey, - title: @escaping (Value) -> String + title: @escaping (Value) -> String, + parse: ((String) -> Value?)? = nil ) { _value = value self.range = range @@ -1077,6 +1082,7 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V self.width = width self.accessibilityLabel = Text(accessibilityLabel) self.title = title + self.parse = parse } init( @@ -1089,11 +1095,12 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V self.width = width accessibilityLabel = Text(verbatim: verbatimAccessibilityLabel) self.title = title + parse = nil } var body: some View { HStack(spacing: 0) { - Text(title(value)) + valueLabel .font(LitheTheme.settingsFont) .foregroundStyle(LitheTheme.primaryText) .monospacedDigit() @@ -1119,6 +1126,30 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V .accessibilityLabel(accessibilityLabel) } + @ViewBuilder + private var valueLabel: some View { + if parse != nil { + TextField(text: $draft) { accessibilityLabel } + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .focused($isEditing) + .onSubmit(commitDraft) + .onAppear { draft = title(value) } + .onChange(of: isEditing) { editing in if !editing { commitDraft() } } + .onChange(of: value) { newValue in if !isEditing { draft = title(newValue) } } + } else { + Text(title(value)) + } + } + + /// Typed values are clamped to the range; text that does not parse restores the value. + private func commitDraft() { + if let parsed = parse?(draft) { + value = min(range.upperBound, max(range.lowerBound, parsed)) + } + draft = title(value) + } + private func stepButton( systemImage: String, isDisabled: Bool, diff --git a/macos/Sources/LitheCoreContracts/Workspace/BuiltInRuleList.swift b/macos/Sources/LitheCoreContracts/Workspace/BuiltInRuleList.swift new file mode 100644 index 0000000000..be51c17ef8 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Workspace/BuiltInRuleList.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A rule list edited as one text box that also shows the built-in rules. +/// +/// Settings store only the user's own entries plus a `!entry` for each +/// built-in the user deleted, so built-ins added by a later release still +/// apply. Comparison ignores case, like the rules themselves. +package enum BuiltInRuleList { + /// The lines to show: built-ins that are still on, then the user's entries. + package static func displayed(stored: [String], builtIns: [String]) -> [String] { + let disabled = FileVisibilityRules.disabledEntries(stored) + let enabled = builtIns.filter { !contains(disabled, $0) } + let custom = stored + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { entry in + // A `!` line for a built-in is shown as that built-in's absence. + !entry.isEmpty && !contains(builtIns, entry) + && !(entry.hasPrefix("!") && contains(builtIns, String(entry.dropFirst()).trimmingCharacters(in: .whitespaces))) + } + return enabled + custom + } + + /// What to store for the edited lines: entries that are not built-in, + /// then a `!` entry for every built-in the lines no longer contain. + package static func stored(lines: [String], builtIns: [String]) -> [String] { + let lines = lines.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + var custom: [String] = [] + for line in lines where !line.isEmpty && !contains(builtIns, line) && !contains(custom, line) { + custom.append(line) + } + let removed = builtIns.filter { !contains(lines, $0) } + return custom + removed.map { "!\($0)" } + } + + private static func contains(_ values: [String], _ value: String) -> Bool { + values.contains { $0.caseInsensitiveCompare(value) == .orderedSame } + } +} diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift index 58227cddc0..a5046fab99 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift @@ -14,8 +14,12 @@ package struct FileVisibilityRules: Hashable, Sendable { package static let builtInHiddenFilePatterns = [ ".DS_Store", "*.pyc", + hiddenConfigurationPattern, ".lithe/run/local.json", ] + /// The project configuration folder is hidden like IDEA's `.idea`; its + /// files are edited through Settings and the run configuration editor. + package static let hiddenConfigurationPattern = ".lithe/**" /// The built-in rules written as Hidden paths patterns, for display. Folder /// names become `**/name/`, the pattern that hides them at any depth. @@ -31,13 +35,41 @@ package struct FileVisibilityRules: Hashable, Sendable { hiddenFilePatterns: builtInHiddenFilePatterns ) + /// A `!` entry in `hiddenFilePatterns` turns a rule off, built-in or not. + /// It stays in `hiddenFilePatterns` so Rust Core, which adds the built-ins + /// again, drops the same ones; it is never matched as a pattern. package init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String]) { + let disabled = Self.disabledEntries(hiddenFilePatterns) self.hiddenDirectoryNames = Self.normalizedEntries( Self.builtInHiddenDirectories + hiddenDirectoryNames - ) + ).filter { !Self.isDisabledDirectory($0, disabled: disabled) } self.hiddenFilePatterns = Self.normalizedEntries( Self.builtInHiddenFilePatterns + hiddenFilePatterns - ) + ).filter { pattern in + !disabled.contains { $0.caseInsensitiveCompare(pattern) == .orderedSame } + } + } + + /// Whether `pattern` is one of `builtInPatterns`, ignoring case. + package static func isBuiltInPattern(_ pattern: String) -> Bool { + builtInPatterns.contains { $0.caseInsensitiveCompare(pattern) == .orderedSame } + } + + /// The entries after a leading `!`. + package static func disabledEntries(_ patterns: [String]) -> [String] { + patterns.compactMap { pattern in + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("!") else { return nil } + return String(trimmed.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + /// A folder is turned off by its displayed form `**/name/` or its bare name. + private static func isDisabledDirectory(_ name: String, disabled: [String]) -> Bool { + disabled.contains { entry in + entry.caseInsensitiveCompare(name) == .orderedSame + || entry.caseInsensitiveCompare("**/\(name)/") == .orderedSame + } } package func isHidden( @@ -67,7 +99,8 @@ package struct FileVisibilityRules: Hashable, Sendable { } let directoryCount = isDirectory == false ? max(0, components.count - 1) : components.count - let folderGlobs = hiddenFilePatterns.compactMap(Self.folderGlob) + let patterns = hiddenFilePatterns.filter { !$0.hasPrefix("!") } + let folderGlobs = patterns.compactMap(Self.folderGlob) if !folderGlobs.isEmpty { // Each ancestor prefix is joined once and shared by every folder // rule, instead of being rebuilt per rule. @@ -80,7 +113,7 @@ package struct FileVisibilityRules: Hashable, Sendable { if hidesFolder { return true } } guard isDirectory != true else { return false } - return hiddenFilePatterns.contains { pattern in + return patterns.contains { pattern in Self.pathGlobMatches(pattern, value: lastComponent) || Self.pathGlobMatches(pattern, value: relativePath) } @@ -102,9 +135,14 @@ package struct FileVisibilityRules: Hashable, Sendable { let relativePath = relativePath(for: url, root: rootURL) guard relativePath.split(separator: "/").first.map(String.init) == Self.watchedConfigurationDirectory else { return false } - return !Self.default.isHiddenPath(url, relativeTo: rootURL) + return !Self.configurationNoise.isHiddenPath(url, relativeTo: rootURL) } + /// The built-in rules without the one hiding the whole configuration folder. + private static let configurationNoise = FileVisibilityRules( + hiddenDirectoryNames: [], hiddenFilePatterns: ["!" + hiddenConfigurationPattern] + ) + package func isHiddenDirectoryName(_ name: String) -> Bool { hiddenDirectoryNames.contains { $0.caseInsensitiveCompare(name) == .orderedSame } } diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index 165ce724f3..0050d4c920 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -99,6 +99,41 @@ public final class ProjectHistoryFeatureModel: ObservableObject { await localHistoryService?.updateVisibilityRules(rules) } + /// Validates ignore rules for the settings page; needs no open project, but + /// only an open one can also report whether Git ignores the path. + public func previewIgnoreRules(path: String?, rules: LocalHistoryVisibilityRules) async -> LocalHistoryIgnorePreview? { + let operations = localHistoryOperations + let workspaceURL = workspaceURLProvider() + return await Task.detached { + operations.ignorePreview(path: path, rules: rules, workspaceURL: workspaceURL) + }.value + } + + /// The open project's history size, or nil without a project. + public func storageUsage() async -> LocalHistoryUsage? { + await localHistoryService?.usage() + } + + /// Deletes every snapshot after cancelling and draining owned writers. + public func clearHistory() async -> Bool { + guard let localHistoryService else { return false } + let writers = Array(backgroundTasks.values) + [seedTask].compactMap { $0 } + writers.forEach { $0.cancel() } + seedTask = nil + backgroundTasks.removeAll() + for writer in writers { await writer.value } + let cleared = await localHistoryService.clear() + if cleared { + localHistoryEntries = [] + selectedLocalHistoryEntry = nil + localHistoryDiffRows = [] + projectLocalHistoryEntries = [] + selectedProjectLocalHistoryEntry = nil + projectLocalHistoryDiffRows = [] + } + return cleared + } + public func seed(files: [URL]) { seedTask?.cancel() guard let localHistoryService else { return } diff --git a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift index 20193de58a..1f478df98e 100644 --- a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift +++ b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift @@ -3,9 +3,56 @@ import Foundation public struct LocalHistoryVisibilityRules: Hashable, Sendable { public let hiddenDirectoryNames: [String] public let hiddenFilePatterns: [String] - public init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String]) { + /// Files the user keeps out of local history only; the project tree still shows them. + /// A `!` glob turns off a built-in folder rule the user removed. + public let ignorePatterns: [String] + public let ignoreRegexes: [String] + /// Storage cap for the project; nil keeps Rust Core's 100 MB default. + public let storageLimitBytes: Int64? + public init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String], + ignorePatterns: [String] = [], ignoreRegexes: [String] = [], + storageLimitBytes: Int64? = nil) { self.hiddenDirectoryNames = hiddenDirectoryNames self.hiddenFilePatterns = hiddenFilePatterns + self.ignorePatterns = ignorePatterns + self.ignoreRegexes = ignoreRegexes + self.storageLimitBytes = storageLimitBytes + } +} + +/// Allocated bytes of one project's local history and the cap eviction keeps. +public struct LocalHistoryUsage: Equatable, Sendable { + public let usedBytes: Int64 + public let limitBytes: Int64 + public init(usedBytes: Int64, limitBytes: Int64) { + self.usedBytes = usedBytes + self.limitBytes = limitBytes + } +} + +/// Which rule keeps a sample path out of local history. +public enum LocalHistoryIgnoreMatch: Equatable, Sendable { + /// Hidden from the project tree, so never recorded. + case hidden + /// The wildcard rule as written, built-in or the user's own. + case pattern(String) + case regex(index: Int) +} + +/// Settings feedback for ignore rules: rejected regexes by list position and the sample-path result. +public struct LocalHistoryIgnorePreview: Equatable, Sendable { + public let regexErrors: [Int: String] + /// The built-in wildcard rules, before the user's changes. + public let builtInIgnorePatterns: [String] + public let matched: LocalHistoryIgnoreMatch? + /// Whether Git ignores the path, shown for comparison; nil outside a repository. + public let gitIgnored: Bool? + public init(regexErrors: [Int: String], builtInIgnorePatterns: [String], + matched: LocalHistoryIgnoreMatch?, gitIgnored: Bool? = nil) { + self.regexErrors = regexErrors + self.builtInIgnorePatterns = builtInIgnorePatterns + self.matched = matched + self.gitIgnored = gitIgnored } } @@ -32,6 +79,9 @@ public protocol LocalHistoryOperations: Sendable { func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? func content(at storageURL: URL, contentPath: String) -> String? func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? + func clear(at storageURL: URL) -> Bool + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? } public protocol LocalHistoryWorkspaceAccess: Sendable { diff --git a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift index 0632727a29..87c7ee6aa5 100644 --- a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift +++ b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift @@ -5,6 +5,7 @@ actor LocalHistoryService { private var visibilityRules: LocalHistoryVisibilityRules private let storageURL: URL private let operations: any LocalHistoryOperations + private var recordingGeneration = UUID() init( workspaceURL: URL, @@ -26,6 +27,17 @@ actor LocalHistoryService { visibilityRules = rules } + func usage() -> LocalHistoryUsage? { + operations.usage(at: storageURL, limitBytes: visibilityRules.storageLimitBytes) + } + + func clear() -> Bool { + // Actor reentrancy lets clear run between files in a caller-owned batch. + // Even batches the feature cannot cancel must stop at this boundary. + recordingGeneration = UUID() + return operations.clear(at: storageURL) + } + func seed(files: [URL]) async { for fileURL in files where !Task.isCancelled { _ = try? recordFile(at: fileURL, reason: .projectBaseline, pruneExpired: false) @@ -34,14 +46,19 @@ actor LocalHistoryService { /// Retention scans the whole history store, so request it only until the /// first snapshot is written. Skipped/unchanged files do not run pruning. - func recordFiles(_ files: [URL], reason: LocalHistoryReason) async { + func recordFiles( + _ files: [URL], + reason: LocalHistoryReason, + yieldBetweenFiles: @Sendable () async -> Void = { await Task.yield() } + ) async { + let generation = recordingGeneration var needsPruning = true for fileURL in files { - guard !Task.isCancelled else { return } + guard !Task.isCancelled, generation == recordingGeneration else { return } if (try? recordFile(at: fileURL, reason: reason, pruneExpired: needsPruning)) != nil { needsPruning = false } - await Task.yield() + await yieldBetweenFiles() } } @@ -113,6 +130,7 @@ actor LocalHistoryService { content: String?, pruneExpired: Bool ) throws -> LocalHistoryEntry? { + guard !Task.isCancelled else { return nil } guard let value = operations.record( at: workspaceURL, storageURL: storageURL, diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index 706ae0f65b..0642180a77 100644 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -104,6 +104,9 @@ private struct TestOperations: LocalHistoryOperations { func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? { [] } func content(at storageURL: URL, contentPath: String) -> String? { nil } func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } + func clear(at storageURL: URL) -> Bool { false } + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } } private final class BatchOperations: LocalHistoryOperations, @unchecked Sendable { @@ -123,4 +126,7 @@ private final class BatchOperations: LocalHistoryOperations, @unchecked Sendable func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? { [] } func content(at storageURL: URL, contentPath: String) -> String? { nil } func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } + func clear(at storageURL: URL) -> Bool { false } + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } } diff --git a/macos/Tests/LitheTests/BuiltInRuleListTests.swift b/macos/Tests/LitheTests/BuiltInRuleListTests.swift new file mode 100644 index 0000000000..85ec826077 --- /dev/null +++ b/macos/Tests/LitheTests/BuiltInRuleListTests.swift @@ -0,0 +1,22 @@ +import LitheCoreContracts +import Testing + +/// Built-in rules shown as editable lines, where deleting a line turns the rule off. +struct BuiltInRuleListTests { + @Test + func deletedBuiltInsAreStoredAsDisabledAndShownAgainWhenRetyped() { + let builtIns = ["Pods", "build"] + let stored = BuiltInRuleList.stored(lines: ["build", " gen ", "", "gen"], builtIns: builtIns) + #expect(stored == ["gen", "!Pods"]) + #expect(BuiltInRuleList.displayed(stored: stored, builtIns: builtIns) == ["build", "gen"]) + #expect(BuiltInRuleList.stored(lines: ["Pods", "build", "gen"], builtIns: builtIns) == ["gen"]) + } + + @Test + func disabledEntriesTurnOffBuiltInHiddenPaths() { + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: ["!**/.git/"]) + #expect(!rules.hiddenDirectoryNames.contains(".git")) + #expect(rules.hiddenFilePatterns.contains("!**/.git/")) + #expect(!FileVisibilityRules.default.hiddenDirectoryNames.isEmpty) + } +} diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index c9f0bb6620..2ca4b800da 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1425,8 +1425,9 @@ struct LitheCoreLogicTests { isDirectory: false ) ) + // Run configurations are edited through the run configuration editor. #expect( - !rules.isHidden( + rules.isHidden( root.appendingPathComponent(".lithe/run/configurations.json"), relativeTo: root, isDirectory: false @@ -1468,10 +1469,13 @@ struct LitheCoreLogicTests { #expect(rules.isHiddenPath(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace)) #expect(rules.isWatchedDespiteHiding(workspace.appendingPathComponent(".lithe"), relativeTo: workspace)) #expect(!rules.isWatchedDespiteHiding(workspace.appendingPathComponent("src/App.java"), relativeTo: workspace)) - #expect(!FileVisibilityRules.default.isHiddenPath(workspace.appendingPathComponent(".lithe"), relativeTo: workspace)) + #expect(FileVisibilityRules.default.isHidden(workspace.appendingPathComponent(".lithe"), relativeTo: workspace, isDirectory: true)) + // Turning the built-in off shows the folder again. + let shown = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: ["!.lithe/**"]) + #expect(!shown.isHidden(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace, isDirectory: false)) - // By default `.lithe` follows the ordinary visibility logic (shown in the - // tree) and is watched; every way of hiding it only changes the display. + // By default `.lithe` is hidden from the tree yet watched; every way of + // hiding it only changes the display. let variants: [FileVisibilityRules] = [ .default, FileVisibilityRules(hiddenDirectoryNames: [".lithe"], hiddenFilePatterns: []), @@ -1491,7 +1495,7 @@ struct LitheCoreLogicTests { ) #expect(variantBatch.workspacePaths == rootConfiguration.sorted(), "\(variant)") } - #expect(!FileVisibilityRules.default.isHiddenPath(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace)) + #expect(FileVisibilityRules.default.isHiddenPath(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace)) } @Test @@ -1529,13 +1533,14 @@ struct LitheCoreLogicTests { // Matching ignores case. #expect(hidden("Modules/A/DIST", ["**/dist/"], isDirectory: true)) // A plain name or a glob without a folder suffix stays a file pattern. - #expect(hidden(".lithe", [".lithe"], isDirectory: false)) - #expect(!hidden(".lithe", [".lithe"], isDirectory: true)) - #expect(!hidden(".lithe/dependencies/index.json", [".lithe"], isDirectory: false)) + // `!.lithe/**` takes the built-in configuration folder rule off. + #expect(hidden(".lithe", [".lithe", "!.lithe/**"], isDirectory: false)) + #expect(!hidden(".lithe", [".lithe", "!.lithe/**"], isDirectory: true)) + #expect(!hidden(".lithe/dependencies/index.json", [".lithe", "!.lithe/**"], isDirectory: false)) #expect(hidden("src/Name.log", ["*.log"], isDirectory: false)) #expect(!hidden("src/Name.log", ["*.log"], isDirectory: true)) - // The default rules leave `.lithe` visible. - #expect(!FileVisibilityRules.default.isHidden(root.appendingPathComponent(".lithe"), relativeTo: root, isDirectory: true)) + // The default rules hide `.lithe` like IDEA's `.idea`. + #expect(FileVisibilityRules.default.isHidden(root.appendingPathComponent(".lithe"), relativeTo: root, isDirectory: true)) } @Test diff --git a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift new file mode 100644 index 0000000000..ec3a72786e --- /dev/null +++ b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift @@ -0,0 +1,164 @@ +import Foundation +@testable import LitheLocalHistoryModule +import Testing +@testable import Lithe + +/// Ignore rules, usage and clearing through the real Rust core, as Settings › Local history uses them. +struct LocalHistoryIgnoreRulesTests { + private let operations = RustLocalHistoryOperations(core: RustCoreBridge()) + + private func preview(_ path: String, patterns: [String] = [], regexes: [String] = [], + hidden: [String] = []) -> LocalHistoryIgnorePreview? { + operations.ignorePreview(path: path, rules: LocalHistoryVisibilityRules( + hiddenDirectoryNames: [], hiddenFilePatterns: hidden, + ignorePatterns: patterns, ignoreRegexes: regexes + ), workspaceURL: nil) + } + + @Test + func previewReportsInvalidRegexLinesAndTheMatchingRule() throws { + let result = try #require(preview("src/Generated/Api.java", patterns: ["*.log", ""], + regexes: ["", "(unclosed", "generated/"])) + #expect(Array(result.regexErrors.keys) == [1]) + #expect(result.matched == .regex(index: 2)) + #expect(result.builtInIgnorePatterns.contains("**/Pods/")) + // Without an open project Git is not asked. + #expect(result.gitIgnored == nil) + + #expect(preview("logs/run.log", patterns: ["*.log"])?.matched == .pattern("*.log")) + #expect(preview("ios/Pods/A/a.h")?.matched == .pattern("**/Pods/")) + #expect(preview(".git/config")?.matched == .hidden) + #expect(preview("src/Main.java")?.matched == nil) + } + + @Test + func builtInFolderRulesCanBeDeleted() { + #expect(preview("ios/Pods/A/a.h", patterns: ["!**/Pods/"])?.matched == nil) + #expect(preview("gen/A.java", patterns: ["**/gen/"])?.matched == .pattern("**/gen/")) + // Showing a hidden built-in folder still leaves it out of local history. + #expect(preview(".git/config", hidden: ["!**/.git/"])?.matched == .pattern("**/.git/")) + #expect(preview(".git/config", patterns: ["!**/.git/"], hidden: ["!**/.git/"])?.matched == nil) + #expect(preview("build/src/Main.java", patterns: ["!**/build/", "*.log"], + hidden: ["!**/build/"])?.matched == nil) + } + + @Test + func previewInputChangesWhenEffectiveHiddenPathsChange() { + let path = "docs/readme.md" + let before = LocalHistoryIgnorePreviewInput(path: path, rules: .init( + hiddenDirectoryNames: [], hiddenFilePatterns: [], ignorePatterns: ["*.log"] + )) + let after = LocalHistoryIgnorePreviewInput(path: path, rules: .init( + hiddenDirectoryNames: [], hiddenFilePatterns: ["docs/*.md"], ignorePatterns: ["*.log"] + )) + #expect(before != after) + #expect(operations.ignorePreview(path: before.path, rules: before.rules, workspaceURL: nil)?.matched == nil) + #expect(operations.ignorePreview(path: after.path, rules: after.rules, workspaceURL: nil)?.matched == .hidden) + } + + @Test + func clearInvalidatesAnInFlightBatchBeforeItCanWriteAnotherFile() async throws { + let operations = ClearingHistoryOperations() + let root = URL(fileURLWithPath: "/in-memory/history") + let service = LocalHistoryService(workspaceURL: root, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), + storage: ClearingHistoryStorage(), operations: operations) + let firstRecorded = TestGate(), resumeBatch = TestGate() + // Pause at the real inter-file suspension, clear, then resume the old batch. + let batch = Task { + await service.recordFiles(["first", "second"].map { root.appendingPathComponent($0) }, + reason: .externalChange, yieldBetweenFiles: { + firstRecorded.open() + _ = await resumeBatch.waitUntilOpen() + }) + } + defer { batch.cancel(); resumeBatch.open() } + let reached = await firstRecorded.waitUntilOpen() + if !reached { + batch.cancel() + resumeBatch.open() + await batch.value + } + try #require(reached) + #expect(operations.paths == ["first"]) + #expect(await service.clear()) + resumeBatch.open() + await batch.value + #expect(operations.paths.isEmpty) + await service.recordFiles([root.appendingPathComponent("new")], reason: .externalChange) + #expect(operations.paths == ["new"]) + } + + @Test @MainActor + func clearCancelsQueuedSaveAndExternalWritersButAllowsNewSaves() async { + let operations = ClearingHistoryOperations() + let root = URL(fileURLWithPath: "/in-memory/history") + let feature = ProjectHistoryFeatureModel( + workspaceAccess: ClearingHistoryWorkspace(), storage: ClearingHistoryStorage(), + localHistoryOperations: operations) + defer { feature.reset() } + feature.openWorkspace(at: root, visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: [])) + let document = LocalHistoryDocumentSnapshot(id: UUID(), url: root.appendingPathComponent("saved"), text: "new") + feature.recordSave(document, previousText: "old") + feature.recordExternalChanges([root.appendingPathComponent("external")]) + #expect(await feature.clearHistory()) + #expect(!feature.hasActiveModuleWork) + #expect(operations.paths.isEmpty) + await feature.recordHistorySnapshot(text: "after clear", for: document.url, reason: .saved) + #expect(operations.paths == ["saved"]) + } + + @Test + func ignoredFilesAreNotRecordedAndClearEmptiesUsage() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-history-ignore-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("workspace", isDirectory: true) + let storage = root.appendingPathComponent("storage", isDirectory: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true) + let rules = LocalHistoryVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [], + ignorePatterns: ["*.log"], ignoreRegexes: ["^build/"]) + func record(_ path: String) -> LocalHistoryEntryPayload? { + operations.record(at: workspace, storageURL: storage, relativePath: path, reason: .saved, + content: "text\n", pruneExpired: false, visibilityRules: rules) + } + + #expect(record("run.log") == nil) + #expect(record("build/out.txt") == nil) + #expect(record("src/App.swift") != nil) + #expect(try #require(operations.usage(at: storage, limitBytes: nil)).usedBytes > 0) + #expect(operations.usage(at: storage, limitBytes: 500 * 1024 * 1024)?.limitBytes == 500 * 1024 * 1024) + + #expect(operations.clear(at: storage)) + #expect(operations.usage(at: storage, limitBytes: nil) == LocalHistoryUsage(usedBytes: 0, limitBytes: 100 * 1024 * 1024)) + #expect(FileManager.default.fileExists(atPath: storage.path)) + } +} + +private struct ClearingHistoryStorage: LocalHistoryStorage { + func applicationSupportDirectory() -> URL { URL(fileURLWithPath: "/in-memory/support") } +} + +private struct ClearingHistoryWorkspace: LocalHistoryWorkspaceAccess { + func fileExists(at url: URL) -> Bool { true } + func readFile(at workspaceURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at workspaceURL: URL, relativePath: String) -> Bool { false } +} + +private final class ClearingHistoryOperations: LocalHistoryOperations, @unchecked Sendable { + private let lock = NSLock() + private var recordedPaths: [String] = [] + var paths: [String] { lock.withLock { recordedPaths } } + func record(at workspaceURL: URL, storageURL: URL, relativePath: String, reason: LocalHistoryReason, + content: String?, pruneExpired: Bool, visibilityRules: LocalHistoryVisibilityRules) -> LocalHistoryEntryPayload? { + lock.withLock { recordedPaths.append(relativePath) } + return .init(id: "00000000-0000-0000-0000-000000000001", timestamp: 1, + relativePath: relativePath, reason: reason.rawValue, contentPath: "snapshot", byteCount: 1) + } + func clear(at storageURL: URL) -> Bool { lock.withLock { recordedPaths.removeAll() }; return true } + func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? { [] } + func content(at storageURL: URL, contentPath: String) -> String? { nil } + func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } +} diff --git a/macos/Tests/LitheTests/LocalHistorySettingsTests.swift b/macos/Tests/LitheTests/LocalHistorySettingsTests.swift new file mode 100644 index 0000000000..e03fe37a33 --- /dev/null +++ b/macos/Tests/LitheTests/LocalHistorySettingsTests.swift @@ -0,0 +1,151 @@ +import AppKit +import LitheLocalHistoryModule +import SwiftUI +import Testing +@testable import Lithe + +@Suite("Local history settings", .serialized) +@MainActor +struct LocalHistorySettingsTests { + @Test(arguments: ["1e3", "-1000", "50abc00", "10.5", "+100", "", "MB", "100 MiB", "100 MB extra", + "999999999999999999999999999999"]) + func invalidLimitInputDoesNotBecomeAnotherNumber(input: String) { + #expect(AppSettings.parseLocalHistoryStorageLimitMegabytes(input) == nil) + } + + @Test(arguments: [("100", 100), ("100 MB", 100), (" 250 mb ", 250), ("1000MB", 1000), ("0", 0)]) + func wholeNumbersAcceptOnlyTheOptionalDisplayedUnit(input: String, expected: Int) { + #expect(AppSettings.parseLocalHistoryStorageLimitMegabytes(input) == expected) + } + + @Test(arguments: [("1e3", 100), ("-1000", 100), ("50abc00", 100), ("999999999999999999999999", 100), + ("250 MB", 250), ("6000", 5000), ("0", 50)], [false, true]) + func nativeLimitCommitRestoresInvalidDraftOrClampsValidInput( + input: (String, Int), submit: Bool + ) async throws { + let form = HistoryLimitForm() + defer { form.close() } + try await form.waitFor("initial value") { form.field?.stringValue == "100 MB" } + let field = try #require(form.field) + try #require(form.window.makeFirstResponder(field)) + try await form.waitFor("field editor") { field.currentEditor() != nil } + let editor = try #require(field.currentEditor() as? NSTextView) + editor.insertText(input.0, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) + try await form.waitFor("dirty draft") { editor.string == input.0 } + #expect(form.settings.localHistoryStorageLimitMegabytes == 100) + if submit { + editor.insertNewline(nil) + } else { + try #require(form.window.makeFirstResponder(nil)) + } + try await form.waitFor("committed value and restored display") { + form.settings.localHistoryStorageLimitMegabytes == input.1 + && (field.currentEditor()?.string ?? field.stringValue) == "\(input.1) MB" + } + #expect(AppSettings(store: form.store).localHistoryStorageLimitMegabytes == input.1) + } + + @Test + func failedClearReportsFailureAndRereadsUsageUntilRetrySucceeds() async { + let model = LocalHistoryStorageSettingsModel() + await model.refreshUsage { .init(usedBytes: 1024, limitBytes: 4096) } + #expect(!model.clearFailed && !model.isClearing) + var calls: [String] = [] + await model.clear(using: { + #expect(model.isClearing && !model.clearFailed) + calls.append("clear-failed") + // Reentrant clicks must not start another deletion or usage read. + await model.clear(using: { calls.append("duplicate"); return true }, readUsage: { + calls.append("duplicate-usage"); return nil + }) + return false + }, readUsage: { + calls.append("remaining-usage") + return .init(usedBytes: 512, limitBytes: 4096) + }) + #expect(calls == ["clear-failed", "remaining-usage"]) + #expect(model.clearFailed && !model.isClearing) + #expect(model.usage?.usedBytes == 512) + await model.clear(using: { + #expect(model.isClearing && !model.clearFailed) + calls.append("retry") + return true + }, readUsage: { + calls.append("empty-usage") + return .init(usedBytes: 0, limitBytes: 4096) + }) + #expect(calls == ["clear-failed", "remaining-usage", "retry", "empty-usage"]) + #expect(!model.clearFailed && !model.isClearing) + #expect(model.usage?.usedBytes == 0) + } + + @Test + func failedUsageReadDoesNotConcealClearFailureOrPreventRetry() async { + let model = LocalHistoryStorageSettingsModel() + await model.clear(using: { false }, readUsage: { nil }) + #expect(model.clearFailed && !model.isClearing && model.usage == nil) + await model.clear(using: { true }, readUsage: { .init(usedBytes: 0, limitBytes: 4096) }) + #expect(!model.clearFailed && model.usage?.usedBytes == 0) + } +} + +@MainActor +private final class HistoryLimitForm { + let store = HistorySettingsStore() + let settings: AppSettings + let host: NSHostingView + let window: NSWindow + + init() { + let settings = AppSettings(store: store) + self.settings = settings + host = NSHostingView(rootView: AnyView(LitheSettingsStepper( + value: Binding(get: { settings.localHistoryStorageLimitMegabytes }, + set: { settings.localHistoryStorageLimitMegabytes = $0 }), + in: AppSettings.localHistoryStorageLimitRange, step: 50, width: 180, + accessibilityLabel: "Local history limit per project", title: { "\($0) MB" }, + parse: AppSettings.parseLocalHistoryStorageLimitMegabytes + ).padding(20).frame(width: 300, height: 100))) + window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + } + + var field: NSTextField? { + func editableField(in view: NSView) -> NSTextField? { + if let field = view as? NSTextField, field.isEditable { return field } + return view.subviews.lazy.compactMap { editableField(in: $0) }.first + } + return editableField(in: host) + } + + func close() { + window.contentView = nil + window.close() + } + + func waitFor(_ description: String, condition: () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if condition() { return } + await Task.yield() + } while clock.now < deadline + Issue.record("Timed out waiting for \(description); field: \(field?.stringValue ?? "missing")") + throw FormTimeout() + } + + private struct FormTimeout: Error {} +} + +private final class HistorySettingsStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index b22a868936..0f69253c28 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -45,10 +45,67 @@ pub(crate) const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ /// Callers first relativize to the most specific explicitly watched root; native /// event-loss flags always bypass this filter and request recovery. pub fn is_workspace_event_relevant(relative: &str) -> bool { + is_workspace_event_relevant_with(relative, &[]) +} + +/// Like [`is_workspace_event_relevant`], but a built-in folder the user turned +/// off with a `!` hidden-path entry is visible, so its events are kept. +fn is_workspace_event_relevant_with(relative: &str, hidden_file_patterns: &[String]) -> bool { + let disabled = disabled_built_ins(hidden_file_patterns); !relative.split(['/', '\\']).any(|component| { - BUILT_IN_HIDDEN_DIRECTORIES - .iter() - .any(|name| component.eq_ignore_ascii_case(name)) + BUILT_IN_HIDDEN_DIRECTORIES.iter().any(|name| { + component.eq_ignore_ascii_case(name) && !is_disabled_directory(name, &disabled) + }) + }) +} + +/// Changed paths for `workspace.filterChanges`: a bare path list, or the +/// paths with the hidden-path entries whose `!` lines turn built-in folders off. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum WorkspaceChangeFilterRequest { + Paths(Vec), + #[serde(rename_all = "camelCase")] + WithRules { + paths: Vec, + #[serde(default)] + hidden_file_patterns: Vec, + }, +} + +/// Keeps the changed paths outside the effective built-in hidden folders. +pub fn filter_workspace_changes(request: WorkspaceChangeFilterRequest) -> Vec { + let (paths, patterns) = match request { + WorkspaceChangeFilterRequest::Paths(paths) => (paths, Vec::new()), + WorkspaceChangeFilterRequest::WithRules { + paths, + hidden_file_patterns, + } => (paths, hidden_file_patterns), + }; + paths + .into_iter() + .filter(|path| is_workspace_event_relevant_with(path, &patterns)) + .collect() +} + +/// The entries after a leading `!`. Such an entry turns a built-in rule off +/// instead of hiding anything; it is never matched as a pattern itself. +fn disabled_built_ins(hidden_file_patterns: &[String]) -> Vec { + hidden_file_patterns + .iter() + .filter_map(|pattern| pattern.trim().strip_prefix('!')) + .map(|pattern| pattern.trim().to_string()) + .collect() +} + +/// A built-in folder is turned off by its displayed form `**/name/` or its bare name. +fn is_disabled_directory(name: &str, disabled: &[String]) -> bool { + disabled.iter().any(|entry| { + entry.eq_ignore_ascii_case(name) + || entry + .strip_prefix("**/") + .and_then(|rest| rest.strip_suffix('/')) + .is_some_and(|rest| rest.eq_ignore_ascii_case(name)) }) } @@ -920,17 +977,30 @@ pub(crate) struct VisibilityRules { } impl VisibilityRules { + /// Adds the built-in rules except those turned off by a `!` entry. + /// + /// Callers may already send built-in names; a turned-off entry is dropped + /// from their lists too, so the caller's copy cannot re-hide it. pub(crate) fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { + let disabled = disabled_built_ins(&hidden_files); let mut directories = BUILT_IN_HIDDEN_DIRECTORIES .iter() .map(|value| (*value).to_string()) .collect::>(); directories.extend(hidden_directories); + directories.retain(|name| !is_disabled_directory(name, &disabled)); let mut files = BUILT_IN_HIDDEN_FILE_PATTERNS .iter() .map(|value| (*value).to_string()) .collect::>(); files.extend(hidden_files); + files.retain(|pattern| { + let pattern = pattern.trim(); + !pattern.starts_with('!') + && !disabled + .iter() + .any(|entry| entry.eq_ignore_ascii_case(pattern)) + }); Self { hidden_directories: normalize(directories), hidden_file_patterns: normalize(files), diff --git a/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index 3ec0ef5c27..7ee001b126 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -14,22 +14,24 @@ const MAX_FILE_SIZE: usize = 2 * 1024 * 1024; const MAX_ENTRIES_PER_FILE: usize = 100; const RETENTION_SECONDS: i64 = 30 * 24 * 60 * 60; const HISTORY_VERSION: u32 = 2; -/// Upper bound for one project's history storage, measured in allocated blocks. +/// Default upper bound for one project's history storage, measured in allocated blocks. const MAX_STORAGE_BYTES: u64 = 100 * 1024 * 1024; -/// Baseline snapshots stop here so edits always keep headroom under the cap and -/// reopening a project does not churn baselines through eviction. -const MAX_BASELINE_STORAGE_BYTES: u64 = MAX_STORAGE_BYTES / 2; +/// Bounds for a host-supplied cap: a tiny cap would evict every edit, and a +/// huge one would let one project fill the disk unnoticed. +const MIN_CONFIGURED_STORAGE_BYTES: u64 = 10 * 1024 * 1024; +const MAX_CONFIGURED_STORAGE_BYTES: u64 = 100 * 1024 * 1024 * 1024; /// Small files still occupy whole filesystem blocks; counting blocks keeps the /// budget close to what Finder and disk cleaners report. const STORAGE_BLOCK_BYTES: u64 = 4096; -/// Dependency, cache, and build output directories that a baseline never needs. +/// Dependency, cache, and build output folders local history ignores by default. /// /// Like IDEA's excluded roots, these are regenerated by tools rather than /// edited, yet a single virtual environment holds tens of thousands of files. +/// Settings show each as the wildcard rule `**/name/`, which the user can delete. /// The list repeats the built-in hidden folders so pruning also reclaims /// baselines that releases before those rules recorded, and adds tool caches /// and agent worktrees that stay visible in the project tree. -const BASELINE_EXCLUDED_DIRECTORY_NAMES: &[&str] = &[ +const BUILT_IN_IGNORED_DIRECTORY_NAMES: &[&str] = &[ ".artifacts", ".build", ".cache", @@ -78,6 +80,125 @@ pub struct HistoryRecordRequest { pub hidden_directory_names: Vec, #[serde(default)] pub hidden_file_patterns: Vec, + #[serde(default)] + pub ignore_rules: HistoryIgnoreRules, + /// Storage cap in bytes; `None` keeps the 100 MB default. + #[serde(default)] + pub storage_limit_bytes: Option, +} + +/// Rules for files whose changes local history should not record. +/// +/// Unlike hidden paths, these only stop new snapshots: the files stay in the +/// tree and their existing history stays listed. Globs use the hidden-path +/// syntax; regexes are case-insensitive and match anywhere in the +/// workspace-relative path unless anchored. An invalid regex never matches. +/// The built-in folders apply as `**/name/` globs unless a `!**/name/` (or +/// `!name`) glob turns one off. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryIgnoreRules { + #[serde(default)] + pub path_patterns: Vec, + #[serde(default)] + pub path_regexes: Vec, +} + +impl HistoryIgnoreRules { + /// The built-in folders no `!` glob turned off. + fn built_in_ignored_directories(&self) -> Vec<&'static str> { + let disabled = self + .path_patterns + .iter() + .filter_map(|pattern| pattern.trim().strip_prefix('!')) + .map(str::trim) + .collect::>(); + BUILT_IN_IGNORED_DIRECTORY_NAMES + .iter() + .copied() + .filter(|name| { + !disabled.iter().any(|entry| { + entry.eq_ignore_ascii_case(name) + || entry.eq_ignore_ascii_case(&built_in_ignore_pattern(name)) + }) + }) + .collect() + } +} + +/// The wildcard rule settings show for a built-in ignored folder. +fn built_in_ignore_pattern(name: &str) -> String { + format!("**/{name}/") +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Rules and an optional sample path for the settings preview. +pub struct HistoryIgnorePreviewRequest { + #[serde(default)] + pub path: Option, + #[serde(default)] + pub ignore_rules: HistoryIgnoreRules, + /// Hidden-path entries, so the preview honours built-ins the user turned off. + #[serde(default)] + pub hidden_file_patterns: Vec, + /// Project root; when given, the preview also asks Git whether it ignores `path`. + #[serde(default)] + pub workspace_root: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +/// One regex rule the regex engine rejected, by its position in the list. +pub struct HistoryIgnoreRegexError { + pub index: usize, + pub message: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +/// Which rule keeps a sample path out of local history. +pub enum HistoryIgnoreMatch { + /// Hidden from the project tree, so never recorded. + Hidden, + /// The wildcard rule as written, built-in or the user's own. + Pattern { + pattern: String, + }, + Regex { + index: usize, + }, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Validation and sample-path result for the local history settings. +pub struct HistoryIgnorePreviewResponse { + pub regex_errors: Vec, + /// The built-in wildcard rules, for settings to show and edit against. + pub built_in_ignore_patterns: Vec, + pub matched: Option, + /// Whether Git ignores the path, for comparison only: local history does not + /// follow Git ignore rules. `None` without a path, a workspace or a repository. + pub git_ignored: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One project's history storage, for usage reporting or clearing. +pub struct HistoryStorageRequest { + pub storage_root: String, + /// Cap reported by `usage`; `None` keeps the 100 MB default. + #[serde(default)] + pub storage_limit_bytes: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Allocated bytes of one project's history and the cap that eviction keeps. +pub struct HistoryUsageResponse { + pub used_bytes: u64, + pub limit_bytes: u64, } #[derive(Debug, Deserialize)] @@ -156,7 +277,9 @@ pub fn record(request: HistoryRecordRequest) -> Result Result= MAX_BASELINE_STORAGE_BYTES { + if is_baseline && storage_usage(&storage) >= baseline_budget(limit) { return Ok(None); } let directory = storage.join(stable_identifier(&relative_path)); @@ -236,10 +360,10 @@ pub fn record(request: HistoryRecordRequest) -> Result MAX_STORAGE_BYTES { + if usage.saturating_add(added) > limit { ensure_reclaimable_usage( &storage, - MAX_STORAGE_BYTES.saturating_sub(added), + limit.saturating_sub(added), usage, &all_entries(&storage), )?; @@ -262,8 +386,8 @@ pub fn record(request: HistoryRecordRequest) -> Result Result<(), CoreError> { Ok(()) } +/// Validates ignore rules and reports which rule, if any, skips `path`. +pub fn ignore_preview( + request: HistoryIgnorePreviewRequest, +) -> Result { + let regex_errors = request + .ignore_rules + .path_regexes + .iter() + .enumerate() + .filter(|(_, pattern)| !pattern.trim().is_empty()) + .filter_map(|(index, pattern)| { + build_ignore_regex(pattern.trim()) + .err() + .map(|error| HistoryIgnoreRegexError { + index, + message: error.to_string(), + }) + }) + .collect(); + let (matched, git_ignored) = match request.path.as_deref().map(str::trim) { + Some(path) if !path.is_empty() => { + let relative = safe_relative_path(path)?; + let matched = preview_match( + &request.ignore_rules, + &request.hidden_file_patterns, + &relative, + ); + // A missing workspace only loses the Git line, not the rule preview. + let git_ignored = match request.workspace_root.as_deref().map(existing_root) { + Some(Ok(root)) => { + crate::git::ignored_paths(&root, std::slice::from_ref(&relative))? + .map(|ignored| ignored.contains(&relative)) + } + _ => None, + }; + (matched, git_ignored) + } + _ => (None, None), + }; + Ok(HistoryIgnorePreviewResponse { + regex_errors, + built_in_ignore_patterns: BUILT_IN_IGNORED_DIRECTORY_NAMES + .iter() + .map(|name| built_in_ignore_pattern(name)) + .collect(), + matched, + git_ignored, + }) +} + +/// Reports one project's allocated history size, rescanning the disk. +pub fn usage(request: HistoryStorageRequest) -> Result { + let storage = storage_root(&request.storage_root)?; + let lock = storage_lock(&storage); + let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + invalidate_storage_usage(&storage); + Ok(HistoryUsageResponse { + used_bytes: storage_usage(&storage), + limit_bytes: storage_limit(request.storage_limit_bytes), + }) +} + +/// The cap to enforce: the host's value kept within sane bounds, or the default. +fn storage_limit(requested: Option) -> u64 { + requested.map_or(MAX_STORAGE_BYTES, |bytes| { + bytes.clamp(MIN_CONFIGURED_STORAGE_BYTES, MAX_CONFIGURED_STORAGE_BYTES) + }) +} + +/// Baseline snapshots stop at half of the cap (50 MB by default) so edits always +/// keep headroom and reopening a project does not churn baselines through eviction. +fn baseline_budget(limit: u64) -> u64 { + limit / 2 +} + +/// Deletes every snapshot of one project. The storage root itself stays. +pub fn clear(request: HistoryStorageRequest) -> Result<(), CoreError> { + let storage = storage_root(&request.storage_root)?; + let lock = storage_lock(&storage); + let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let result = fs::read_dir(&storage) + .map_err(CoreError::from)? + .filter_map(Result::ok) + .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) + .try_for_each(|entry| fs::remove_dir_all(entry.path())); + invalidate_storage_usage(&storage); + result.map_err(CoreError::from) +} + +fn preview_match( + rules: &HistoryIgnoreRules, + hidden_file_patterns: &[String], + relative_path: &str, +) -> Option { + if VisibilityRules::new(Vec::new(), hidden_file_patterns.to_vec()) + .is_hidden(relative_path, false) + { + return Some(HistoryIgnoreMatch::Hidden); + } + ignored_rule(rules, relative_path) +} + +/// The first rule that matches: built-in folders, the user's globs, then regexes. +fn ignored_rule(rules: &HistoryIgnoreRules, relative_path: &str) -> Option { + // Folder names are compared directly; baseline seeding asks for thousands of files. + let mut folders = relative_path.split('/').collect::>(); + folders.pop(); + let built_in = rules.built_in_ignored_directories(); + if let Some(name) = folders.iter().find_map(|folder| { + built_in + .iter() + .find(|name| folder.eq_ignore_ascii_case(name)) + }) { + return Some(HistoryIgnoreMatch::Pattern { + pattern: built_in_ignore_pattern(name), + }); + } + // Match only this history glob. Re-adding default hidden rules here would + // override the caller's disabled built-ins and misreport unrelated globs. + let found = rules + .path_patterns + .iter() + .map(|pattern| pattern.trim()) + .find(|pattern| { + !pattern.is_empty() + && !pattern.starts_with('!') + && VisibilityRules { + hidden_directories: Vec::new(), + hidden_file_patterns: vec![pattern.to_string()], + } + .is_hidden(relative_path, false) + }); + if let Some(pattern) = found { + return Some(HistoryIgnoreMatch::Pattern { + pattern: pattern.to_string(), + }); + } + if rules.path_regexes.is_empty() { + return None; + } + compiled_ignore_regexes(&rules.path_regexes) + .iter() + .position(|regex| { + regex + .as_ref() + .is_some_and(|regex| regex.is_match(relative_path)) + }) + .map(|index| HistoryIgnoreMatch::Regex { index }) +} + +fn build_ignore_regex(pattern: &str) -> Result { + regex::RegexBuilder::new(pattern) + .case_insensitive(true) + .size_limit(1 << 20) + .build() +} + +/// Compiles the regex list once per distinct list, because baseline seeding +/// records thousands of files with the same rules. +fn compiled_ignore_regexes(patterns: &[String]) -> Arc>> { + type Compiled = (Vec, Arc>>); + static CACHE: OnceLock>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(None)); + if let Ok(guard) = cache.lock() { + if let Some((cached, compiled)) = guard.as_ref() { + if cached.as_slice() == patterns { + return Arc::clone(compiled); + } + } + } + let compiled = Arc::new( + patterns + .iter() + .map(|pattern| { + let trimmed = pattern.trim(); + (!trimmed.is_empty()) + .then(|| build_ignore_regex(trimmed).ok()) + .flatten() + }) + .collect::>(), + ); + if let Ok(mut guard) = cache.lock() { + *guard = Some((patterns.to_vec(), Arc::clone(&compiled))); + } + compiled +} + impl StoredEntry { fn into_response(self) -> HistoryEntryResponse { HistoryEntryResponse { @@ -506,17 +817,17 @@ fn trim_entries(directory: &Path, storage: &Path) { } } -/// Removes expired entries and baselines that current exclusion rules reject. +/// Removes expired entries and baselines that current ignore rules reject. /// /// Older releases seeded every visible file, including virtual environments and /// nested worktrees; dropping those baselines here reclaims that space without /// touching snapshots of real edits. -fn prune_expired(storage: &Path) { +fn prune_expired(storage: &Path, rules: &HistoryIgnoreRules) { let cutoff = unix_seconds() - RETENTION_SECONDS; let mut removed = false; for entry in all_entries(storage) { let excluded_baseline = entry.reason == "projectBaseline" - && has_excluded_baseline_component(&entry.relative_path); + && ignored_rule(rules, &entry.relative_path).is_some(); if entry.timestamp < cutoff || excluded_baseline { remove_entry(storage, &entry); removed = true; @@ -692,24 +1003,11 @@ fn invalidate_storage_usage(storage: &Path) { } } -fn has_excluded_baseline_component(relative_path: &str) -> bool { - let mut components = relative_path.split('/').collect::>(); - components.pop(); - components.iter().any(|component| { - BASELINE_EXCLUDED_DIRECTORY_NAMES - .iter() - .any(|name| component.eq_ignore_ascii_case(name)) - }) -} - -/// Rejects generated directories and files owned by a nested repository. +/// Rejects files owned by a nested repository. /// /// A nested `.git` marks another checkout, such as a worktree or vendored clone, /// whose files belong to that repository's own history rather than this project. fn is_excluded_from_baseline(workspace: &Path, relative_path: &str) -> bool { - if has_excluded_baseline_component(relative_path) { - return true; - } let mut directory = workspace.to_path_buf(); let mut components = relative_path.split('/').collect::>(); components.pop(); @@ -844,6 +1142,24 @@ mod tests { prune_expired: reason != "projectBaseline", hidden_directory_names: Vec::new(), hidden_file_patterns: Vec::new(), + ignore_rules: HistoryIgnoreRules::default(), + storage_limit_bytes: None, + } + } + + #[test] + fn configured_limit_is_kept_within_bounds() { + assert_eq!(storage_limit(None), MAX_STORAGE_BYTES); + assert_eq!(storage_limit(Some(300 * 1024 * 1024)), 300 * 1024 * 1024); + assert_eq!(storage_limit(Some(0)), MIN_CONFIGURED_STORAGE_BYTES); + assert_eq!(storage_limit(Some(u64::MAX)), MAX_CONFIGURED_STORAGE_BYTES); + assert_eq!(baseline_budget(storage_limit(None)), 50 * 1024 * 1024); + } + + fn ignore_rules(patterns: &[&str], regexes: &[&str]) -> HistoryIgnoreRules { + HistoryIgnoreRules { + path_patterns: patterns.iter().map(|value| value.to_string()).collect(), + path_regexes: regexes.iter().map(|value| value.to_string()).collect(), } } @@ -921,16 +1237,20 @@ mod tests { .unwrap(); assert!(response.is_some()); - // Explicit edits inside excluded folders are still the user's history. - let saved = record(record_request( - &workspace, - &storage, - "tools/.uv-cache/wheel/site.py", - "saved", - Some("import sys\n"), - )) - .unwrap(); - assert!(saved.is_some()); + // Edits there are ignored too, until the built-in rule is deleted. + let save = |patterns: &[&str]| { + let mut request = record_request( + &workspace, + &storage, + "tools/.uv-cache/wheel/site.py", + "saved", + Some("import sys\n"), + ); + request.ignore_rules = ignore_rules(patterns, &[]); + record(request).unwrap() + }; + assert!(save(&[]).is_none()); + assert!(save(&["!**/.uv-cache/"]).is_some()); fs::remove_dir_all(root).unwrap(); } @@ -1135,7 +1455,7 @@ mod tests { usage_cache() .lock() .unwrap() - .insert(canonical.clone(), MAX_BASELINE_STORAGE_BYTES); + .insert(canonical.clone(), baseline_budget(MAX_STORAGE_BYTES)); let baseline = record(record_request( &workspace, @@ -1158,4 +1478,253 @@ mod tests { invalidate_storage_usage(&canonical); fs::remove_dir_all(root).unwrap(); } + + #[test] + fn ignore_rules_skip_matching_files_but_keep_existing_history() { + let workspace = temporary_root("history-ignore-workspace"); + let storage = temporary_root("history-ignore-storage"); + write_file(&workspace, "src/App.swift", "one\n"); + write_file(&workspace, "logs/run.log", "one\n"); + assert!(record(record_request( + &workspace, + &storage, + "src/App.swift", + "saved", + Some("one\n") + )) + .unwrap() + .is_some()); + + let ignored = |path: &str, content: &str| { + let mut request = record_request(&workspace, &storage, path, "saved", Some(content)); + request.ignore_rules = ignore_rules(&["*.log"], &[r"^src/.*\.swift$"]); + record(request).unwrap() + }; + assert!(ignored("logs/run.log", "two\n").is_none()); + assert!(ignored("src/App.swift", "two\n").is_none()); + assert!(ignored("README.md", "two\n").is_some()); + + let listed = entries(HistoryEntriesRequest { + workspace_root: workspace.to_string_lossy().into_owned(), + storage_root: storage.to_string_lossy().into_owned(), + path: Some("src/App.swift".into()), + hidden_directory_names: Vec::new(), + hidden_file_patterns: Vec::new(), + }) + .unwrap(); + assert_eq!(listed.entries.len(), 1); + } + + #[test] + fn ignore_regexes_are_case_insensitive_and_invalid_ones_never_match() { + let rules = ignore_rules(&[], &["(unclosed", "GENERATED"]); + assert_eq!( + ignored_rule(&rules, "src/generated/Api.java"), + Some(HistoryIgnoreMatch::Regex { index: 1 }) + ); + assert_eq!(ignored_rule(&rules, "src/Main.java"), None); + assert_eq!( + ignored_rule(&ignore_rules(&["", " "], &[""]), "a.txt"), + None + ); + } + + #[test] + fn ignore_preview_reports_errors_and_the_rule_that_matches() { + let preview = |path: &str| { + ignore_preview(HistoryIgnorePreviewRequest { + path: Some(path.into()), + ignore_rules: ignore_rules(&["docs/*.md"], &["(", r"\.tmp$"]), + hidden_file_patterns: Vec::new(), + workspace_root: None, + }) + .unwrap() + }; + let result = preview("docs/guide.md"); + assert_eq!(result.regex_errors.len(), 1); + assert_eq!(result.regex_errors[0].index, 0); + assert!(result + .built_in_ignore_patterns + .contains(&"**/Pods/".to_string())); + assert_eq!( + result.matched, + Some(HistoryIgnoreMatch::Pattern { + pattern: "docs/*.md".into() + }) + ); + assert_eq!( + preview("a/b.TMP").matched, + Some(HistoryIgnoreMatch::Regex { index: 1 }) + ); + assert_eq!( + preview("ios/Pods/Lib/a.h").matched, + Some(HistoryIgnoreMatch::Pattern { + pattern: "**/Pods/".into() + }) + ); + assert_eq!( + preview(".git/config").matched, + Some(HistoryIgnoreMatch::Hidden) + ); + assert_eq!(preview("src/Main.java").matched, None); + assert!(ignore_preview(HistoryIgnorePreviewRequest { + path: Some("../outside".into()), + ignore_rules: HistoryIgnoreRules::default(), + hidden_file_patterns: Vec::new(), + workspace_root: None, + }) + .is_err()); + assert_eq!(result.git_ignored, None); + } + + #[test] + fn ignore_preview_reports_whether_git_ignores_the_path() { + let workspace = temporary_root("history-preview-git"); + fs::create_dir_all(&workspace).unwrap(); + let git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(&workspace) + .output() + .is_ok_and(|output| output.status.success()) + }; + let preview = |path: &str| { + ignore_preview(HistoryIgnorePreviewRequest { + path: Some(path.into()), + ignore_rules: HistoryIgnoreRules::default(), + hidden_file_patterns: Vec::new(), + workspace_root: Some(workspace.to_string_lossy().into_owned()), + }) + .unwrap() + .git_ignored + }; + // Outside a repository Git cannot answer. + assert_eq!(preview("run.log"), None); + if !git(&["init", "-q"]) { + return; + } + write_file(&workspace, ".gitignore", "*.log\n"); + assert_eq!(preview("logs/run.log"), Some(true)); + assert_eq!(preview("src/Main.java"), Some(false)); + } + + #[test] + fn built_in_ignored_folders_can_be_turned_off() { + let rules = ignore_rules(&[" !**/Pods/ ", "!.uv-cache", "**/generated/"], &[]); + assert_eq!(preview_match(&rules, &[], "ios/Pods/Lib/a.h"), None); + assert_eq!(preview_match(&rules, &[], "tools/.uv-cache/a.py"), None); + assert_eq!( + preview_match(&rules, &[], "src/generated/A.java"), + Some(HistoryIgnoreMatch::Pattern { + pattern: "**/generated/".into() + }) + ); + assert_eq!( + preview_match(&ignore_rules(&[], &[]), &[], "tools/.uv-cache/a.py"), + Some(HistoryIgnoreMatch::Pattern { + pattern: "**/.uv-cache/".into() + }) + ); + // A turned-off hidden folder is visible again; `build` is still ignored. + assert_eq!( + preview_match(&rules, &["!**/build/".into()], "build/out.txt"), + Some(HistoryIgnoreMatch::Pattern { + pattern: "**/build/".into() + }) + ); + assert_eq!( + preview_match(&rules, &[], "build/out.txt"), + Some(HistoryIgnoreMatch::Hidden) + ); + } + + #[test] + fn unrelated_history_globs_do_not_reenable_disabled_hidden_rules() { + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let root = temporary_root("history-disabled-hidden"); + let _cleanup = Cleanup(root.clone()); + let workspace = root.join("workspace"); + let storage = root.join("storage"); + fs::create_dir_all(&workspace).unwrap(); + for (path, disabled) in [ + ("build/src/Main.java", "**/build/"), + ("src/cached.pyc", "*.pyc"), + ] { + let hidden = vec![format!("!{disabled}")]; + let rules = ignore_rules(&[&format!("!{disabled}"), "*.log", "**/generated/"], &[]); + assert_eq!(preview_match(&rules, &hidden, path), None); + let mut request = record_request(&workspace, &storage, path, "saved", Some("text")); + request.hidden_file_patterns = hidden; + request.ignore_rules = rules; + assert!(record(request).unwrap().is_some()); + } + let rules = ignore_rules(&["!**/build/", "*.log", "**/generated/"], &[]); + for (path, pattern) in [ + ("build/run.log", "*.log"), + ("build/generated/Main.java", "**/generated/"), + ] { + assert_eq!( + preview_match(&rules, &["!**/build/".into()], path), + Some(HistoryIgnoreMatch::Pattern { + pattern: pattern.into() + }) + ); + } + } + + #[test] + fn usage_counts_allocated_blocks_and_clear_removes_every_snapshot() { + let workspace = temporary_root("history-usage-workspace"); + let storage = temporary_root("history-usage-storage"); + write_file(&workspace, "a.txt", "a\n"); + let request = || HistoryStorageRequest { + storage_root: storage.to_string_lossy().into_owned(), + storage_limit_bytes: None, + }; + assert_eq!(usage(request()).unwrap().used_bytes, 0); + record(record_request( + &workspace, + &storage, + "a.txt", + "saved", + Some("a\n"), + )) + .unwrap(); + record(record_request( + &workspace, + &storage, + "b.txt", + "saved", + Some("b\n"), + )) + .unwrap(); + let measured = usage(request()).unwrap(); + // Two snapshots and two metadata files, one block each. + assert_eq!(measured.used_bytes, 4 * STORAGE_BLOCK_BYTES); + assert_eq!(measured.limit_bytes, MAX_STORAGE_BYTES); + let configured = usage(HistoryStorageRequest { + storage_limit_bytes: Some(500 * 1024 * 1024), + ..request() + }) + .unwrap(); + assert_eq!(configured.limit_bytes, 500 * 1024 * 1024); + + clear(request()).unwrap(); + assert_eq!(usage(request()).unwrap().used_bytes, 0); + assert!(storage.is_dir()); + assert!(record(record_request( + &workspace, + &storage, + "a.txt", + "saved", + Some("a\n") + )) + .unwrap() + .is_some()); + } } diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 2e734dc91a..7f5dc81e52 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -24,4 +24,6 @@ pub(crate) use maven_dependency_tree::{ }; pub(crate) use maven_test_reports::MavenTestReportsRequest; -pub use files::is_workspace_event_relevant; +pub use files::{ + filter_workspace_changes, is_workspace_event_relevant, WorkspaceChangeFilterRequest, +}; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index f27e694ed3..2f613c9945 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -222,6 +222,12 @@ pub enum CoreCommand { HistoryRename, /// Deletes one history snapshot (`history.delete`). HistoryDelete, + /// Validates local-history ignore rules against a sample path (`history.ignorePreview`). + HistoryIgnorePreview, + /// Reports one project's history storage size (`history.usage`). + HistoryUsage, + /// Deletes every snapshot of one project (`history.clear`). + HistoryClear, /// Inspects a declared Maven reactor (`maven.scan`). MavenScan, /// Produces a deterministic Maven invocation (`maven.launchPlan`). @@ -635,6 +641,9 @@ impl CoreCommand { "history.relocate" => Some(Self::HistoryRelocate), "history.rename" => Some(Self::HistoryRename), "history.delete" => Some(Self::HistoryDelete), + "history.ignorePreview" => Some(Self::HistoryIgnorePreview), + "history.usage" => Some(Self::HistoryUsage), + "history.clear" => Some(Self::HistoryClear), "maven.scan" => Some(Self::MavenScan), "maven.launchPlan" => Some(Self::MavenLaunchPlan), "execution.resolveEnvironment" => Some(Self::ExecutionResolveEnvironment), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 6621e4974d..2c1cf77947 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -29,8 +29,9 @@ use crate::project::{ WorkspaceSnapshotRequest, }; use crate::project::{ - HistoryContentRequest, HistoryDeleteRequest, HistoryEntriesRequest, HistoryRecordRequest, - HistoryRelocateRequest, HistoryRenameRequest, + HistoryContentRequest, HistoryDeleteRequest, HistoryEntriesRequest, + HistoryIgnorePreviewRequest, HistoryRecordRequest, HistoryRelocateRequest, + HistoryRenameRequest, HistoryStorageRequest, }; use crate::project::{ MarkdownRenderRequest, MavenDependenciesRequest, MavenDependencyPlanRequest, @@ -555,13 +556,12 @@ fn execute(request: &str) -> CoreResponse { } } CoreCommand::WorkspaceFilterChanges => { - match serde_json::from_value::>(parsed.payload) { - Ok(paths) => CoreResponse::success( + match serde_json::from_value::( + parsed.payload, + ) { + Ok(request) => CoreResponse::success( id, - serde_json::json!(paths - .into_iter() - .filter(|path| crate::project::is_workspace_event_relevant(path)) - .collect::>()), + serde_json::json!(crate::project::filter_workspace_changes(request)), ), Err(error) => CoreResponse::failure( id, @@ -848,6 +848,51 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::HistoryIgnorePreview => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid history ignore preview request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::project::ignore_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("history ignore preview should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::HistoryUsage => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history usage request") + .with_details(error.to_string()) + }) + .and_then(crate::project::usage) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("history usage should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::HistoryClear => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history clear request") + .with_details(error.to_string()) + }) + .and_then(crate::project::clear) + { + Ok(()) => CoreResponse::success(id, serde_json::json!({"cleared": true})), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::MavenScan => match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Maven scan request") diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index ab65585557..d14df602a8 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -760,6 +760,24 @@ fn workspace_snapshot_skips_python_environments_and_bytecode_caches() { assert!(crate::project::files::is_workspace_event_relevant( "app/main.py" )); + // A built-in folder turned off with `!` is shown again, and its events kept. + let turned_off = crate::project::files::VisibilityRules::new( + vec!["venv".into()], + vec!["!**/venv/".into(), "!*.pyc".into()], + ); + assert!(!turned_off.is_hidden("venv/lib/site.py", false)); + assert!(!turned_off.is_hidden("app/a.pyc", false)); + assert!(turned_off.is_hidden(".venv/lib/site.py", false)); + assert_eq!( + crate::project::filter_workspace_changes( + serde_json::from_value(serde_json::json!({ + "paths": ["venv/lib/site.py", ".venv/lib/site.py", "app/main.py"], + "hiddenFilePatterns": ["!**/venv/"] + })) + .unwrap() + ), + vec!["venv/lib/site.py".to_string(), "app/main.py".to_string()] + ); fs::remove_dir_all(root).expect("temporary fixture should be removable"); } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 6479b3f9cd..5716fa0dee 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -977,7 +977,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | | `network.proxyPolicy` | Normalize the stored proxy settings into the policy a network stack must apply (mode, server, credentials, exclusions) | | `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index; the built-in hidden names are skipped without being read, and include `node_modules`, Python virtual environments (`.venv`, `venv`), `__pycache__` and `*.pyc` files; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | -| `workspace.filterChanges` | Filter an array of workspace-relative paths against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | +| `workspace.filterChanges` | Filter an array of workspace-relative paths, or `{ paths, hiddenFilePatterns }` whose `!` entries turn default exclusions off, against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | | `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | | `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | | `workspace.search` | Search visible file names and UTF-8 text files | @@ -992,6 +992,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `history.relocate` | Move a file's history records after a rename | | `history.rename` | Set or clear a user-visible label on a history entry | | `history.delete` | Delete one history entry and its snapshot | +| `history.ignorePreview` | Validate local history ignore rules and report which rule skips a sample path | +| `history.usage` | Report one project's allocated history size and the storage cap | +| `history.clear` | Delete every snapshot of one project's history | | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.launchPlan` | Produce a deterministic Maven invocation from a versioned project context | | `execution.resolveEnvironment` | Merge native `inherited` string values, selected `javaHome`/`mavenJavaHome` for the current `toolchain`, and `overrides` into `{ environment }`. `caseInsensitive: true` canonicalizes Windows keys to uppercase. Maven uses Maven JDK with project-JDK fallback; Java uses project JDK. String overrides win; structured `{ toolchain, property: "home" }` bindings use the current step's home for JAVA_HOME, and the named toolchain for other variables. Unknown bindings are omitted. | @@ -2020,7 +2023,9 @@ matcher itself, so `**/dist/` covers a root-level `dist` as well as nested ones (no client has to add the root form). An entry ending in `/*` or `/**` is the same folder rule as one ending in `/`, so `.lithe/*` hides the folder and everything below it rather than leaving an empty folder; ordinary file globs -and plain names (such as `.lithe`) retain their file-only semantics. The macOS file +and plain names (such as `.lithe`) retain their file-only semantics. macOS hides the +workspace-root `.lithe` folder by default with the built-in entry `.lithe/**`, like IDEA's +`.idea`; deleting that line (stored as `!.lithe/**`) shows it again. The macOS file watcher is the one place that does not follow the hiding rules for the workspace-root `.lithe` folder: hiding it removes it from the tree and listings, but its file events are still delivered (except built-in noise such as `.lithe/run/local.json`) because editing its JSON @@ -3132,6 +3137,32 @@ are ignored. `history.entries` returns Unix-second timestamps and relative `history.delete` validate both the relative file path and entry ID before changing stored metadata. +A `hiddenFilePatterns` entry starting with `!` turns a rule off instead of +adding one: `!**/name/` or `!name` removes that hidden folder (built-in or not), +and `!pattern` removes an equal file pattern. `!` entries never match a path. + +`history.record` also accepts optional `ignoreRules: { pathPatterns, pathRegexes }` +and an optional `storageLimitBytes` (default +100 MiB, clamped to 10 MiB–100 GiB); baselines stop at half of that cap. +Patterns use the hidden-path glob syntax; regexes use Rust `regex` syntax, +case-insensitive, matched unanchored against the `/`-separated relative path. +A matching file is not recorded, existing entries stay listed, and an invalid +or empty regex never matches. Built-in dependency, cache and build output +folders (such as `node_modules`, `.venv`, `target`) also apply as `**/name/` +patterns, for baselines and edits alike; a `!**/name/` or `!name` pattern turns +one off, and saving drops baselines the current rules reject. `history.ignorePreview` accepts the +same `ignoreRules`, optional `hiddenFilePatterns`, an optional relative +`path` and an optional `workspaceRoot`, and returns `regexErrors` (`{ index, message }` per rejected regex), the +built-in patterns as `builtInIgnorePatterns`, and `matched`: `null` or an object tagged by +`kind` — `hidden`, `pattern` (the matching `pattern`, built-in or the user's) or +`regex` (`index` into its list), plus `gitIgnored`: whether `git check-ignore` ignores +`path` under `workspaceRoot`, or `null` without a path, workspace or repository. +It is informational; local history does not follow Git ignore rules. `history.usage` and `history.clear` accept +`storageRoot`, and `history.usage` also the optional `storageLimitBytes` it +reports back as `limitBytes`; usage rescans the disk and returns `{ usedBytes, limitBytes }` in +allocated bytes, and clear removes every snapshot directory while keeping the +storage root, returning `{ "cleared": true }`. + `maven.scan` accepts `{ "root": string, "paths"?: string[] }` and returns `null` when neither the root nor the supplied visible workspace-relative paths contain a readable `pom.xml`. Candidates are tried in shallowest-first order, diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 0428764779..ff68fb19ea 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9702,7 +9702,34 @@ "native.couldNotBeReachedCheckYourInternetProxyOrVPNConnectionAndTryAgain": "{arg1} could not be reached. Check your internet, proxy, or VPN connection and try again.", "native.aSecureConnectionToCouldNotBeEstablishedCheckTLSInspectionProxyVPNOrSystemCertificateSettings": "A secure connection to {arg1} could not be established. Check TLS inspection, proxy, VPN, or system certificate settings.", "native.returnedHTTP": "{arg1} returned HTTP {arg2}.", - "native.returnedAnUnexpectedResponse": "{arg1} returned an unexpected response." + "native.returnedAnUnexpectedResponse": "{arg1} returned an unexpected response.", + "native.localHistory.variant3": "Local history", + "native.wildcard": "Wildcard", + "native.regularExpression.variant2": "Regular expression", + "native.wildcardIgnoreRules": "Wildcard ignore rules", + "native.regularExpressionIgnoreRules": "Regular expression ignore rules", + "native.testPath": "Test path", + "native.clearThisProjectSLocalHistory": "Clear this project's local history?", + "native.everySavedVersionOfThisProjectSFilesIsDeletedOpenFilesAreNotChanged": "Every saved version of this project's files is deleted. Open files are not changed.", + "native.used.variant3": "Used", + "native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst": "When the limit is reached, the oldest versions are removed first.", + "native.clearing": "Clearing…", + "native.clearProjectHistory": "Clear Project History…", + "native.openAProjectToSeeHowMuchSpaceItsHistoryUses": "Open a project to see how much space its history uses.", + "native.lineLldIsNotAValidRegularExpressionAndIsSkipped": "Line {arg1} is not a valid regular expression and is skipped.", + "native.recorded": "Recorded", + "native.hiddenInTheProjectTreeNotRecorded": "Hidden in the project tree, not recorded", + "native.ignoredByWildcardRule": "Ignored by wildcard rule {arg1}", + "native.ignoredByRegularExpressionLineLld": "Ignored by regular expression line {arg1}", + "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul16320b202b": "Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.", + "native.limitPerProject": "Limit per project", + "native.localHistoryLimitPerProject": "Local history limit per project", + "native.typeAPathInsideTheProjectToCheckItAgainstTheRulesAboveAsYouEditThemWhetherLocalH0f31e93916": "Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history.", + "native.localHistoryFormat": "Local history: {arg1}", + "native.gitIgnored": "Git: ignored", + "native.gitNotIgnored": "Git: not ignored", + "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99": "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.", + "history.clearFailed": "Could not clear this project's local history. Check storage permissions and try again." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index cc1a00bb74..9542eea2a9 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9702,7 +9702,34 @@ "native.couldNotBeReachedCheckYourInternetProxyOrVPNConnectionAndTryAgain": "无法连接 {arg1}。请检查网络、代理或 VPN 后重试。", "native.aSecureConnectionToCouldNotBeEstablishedCheckTLSInspectionProxyVPNOrSystemCertificateSettings": "无法与 {arg1} 建立安全连接。请检查 TLS 检查、代理、VPN 或系统证书设置。", "native.returnedHTTP": "{arg1} 返回了 HTTP {arg2}。", - "native.returnedAnUnexpectedResponse": "{arg1} 返回了无法识别的响应。" + "native.returnedAnUnexpectedResponse": "{arg1} 返回了无法识别的响应。", + "native.localHistory.variant3": "本地历史", + "native.wildcard": "通配符", + "native.regularExpression.variant2": "正则表达式", + "native.wildcardIgnoreRules": "通配符忽略规则", + "native.regularExpressionIgnoreRules": "正则表达式忽略规则", + "native.testPath": "测试路径", + "native.clearThisProjectSLocalHistory": "清空此项目的本地历史?", + "native.everySavedVersionOfThisProjectSFilesIsDeletedOpenFilesAreNotChanged": "此项目文件的所有已保存版本都会被删除,已打开的文件不受影响。", + "native.used.variant3": "已用", + "native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst": "达到上限时,最早的版本会先被删除。", + "native.clearing": "正在清空…", + "native.clearProjectHistory": "清空项目历史…", + "native.openAProjectToSeeHowMuchSpaceItsHistoryUses": "打开项目后可查看其历史占用的空间。", + "native.lineLldIsNotAValidRegularExpressionAndIsSkipped": "第 {arg1} 行不是有效的正则表达式,已跳过。", + "native.recorded": "会记录", + "native.hiddenInTheProjectTreeNotRecorded": "已在项目树中隐藏,不记录", + "native.ignoredByWildcardRule": "被通配符规则 {arg1} 忽略", + "native.ignoredByRegularExpressionLineLld": "被正则表达式第 {arg1} 行忽略", + "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul16320b202b": "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。列表开头是内置的依赖、缓存和构建输出文件夹,例如 `**/node_modules/`;删掉一行即可重新记录该文件夹。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。", + "native.limitPerProject": "每个项目上限", + "native.localHistoryLimitPerProject": "每个项目的本地历史上限", + "native.typeAPathInsideTheProjectToCheckItAgainstTheRulesAboveAsYouEditThemWhetherLocalH0f31e93916": "输入项目内的路径,随着你编辑上面的规则实时检查:本地历史是否会记录这个文件,以及 Git 是否忽略它。Git 的忽略规则不影响本地历史。", + "native.localHistoryFormat": "本地历史:{arg1}", + "native.gitIgnored": "Git:已忽略", + "native.gitNotIgnored": "Git:未忽略", + "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99": "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/`、`/*` 或 `/**` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级。不带结尾斜杠的规则只隐藏文件:`.lithe` 隐藏名为 .lithe 的文件,而不是文件夹。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/**` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n列表开头是内置规则。删掉一行即可重新显示这些文件,再输入回来就会重新隐藏。", + "history.clearFailed": "无法清空此项目的本地历史。请检查存储目录权限后重试。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index f2fdc944b3..764579b111 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -92834,6 +92834,323 @@ "parameters": { "arg1": "string" } + }, + { + "id": "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99", + "description": "Native UI: One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.", + "platforms": [ + "macos" + ], + "macos": { + "key": "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.", + "arguments": [] + } + }, + { + "id": "native.localHistory.variant3", + "description": "Native UI: Local history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local history", + "arguments": [] + } + }, + { + "id": "native.wildcard", + "description": "Native UI: Wildcard", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wildcard", + "arguments": [] + } + }, + { + "id": "native.regularExpression.variant2", + "description": "Native UI: Regular expression", + "platforms": [ + "macos" + ], + "macos": { + "key": "Regular expression", + "arguments": [] + } + }, + { + "id": "native.wildcardIgnoreRules", + "description": "Native UI: Wildcard ignore rules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wildcard ignore rules", + "arguments": [] + } + }, + { + "id": "native.regularExpressionIgnoreRules", + "description": "Native UI: Regular expression ignore rules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Regular expression ignore rules", + "arguments": [] + } + }, + { + "id": "native.testPath", + "description": "Native UI: Test path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Test path", + "arguments": [] + } + }, + { + "id": "native.clearThisProjectSLocalHistory", + "description": "Native UI: Clear this project's local history?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear this project's local history?", + "arguments": [] + } + }, + { + "id": "native.everySavedVersionOfThisProjectSFilesIsDeletedOpenFilesAreNotChanged", + "description": "Native UI: Every saved version of this project's files is deleted. Open files are not changed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Every saved version of this project's files is deleted. Open files are not changed.", + "arguments": [] + } + }, + { + "id": "native.used.variant3", + "description": "Native UI: Used", + "platforms": [ + "macos" + ], + "macos": { + "key": "Used", + "arguments": [] + } + }, + { + "id": "native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst", + "description": "Native UI: When the limit is reached, the oldest versions are removed first.", + "platforms": [ + "macos" + ], + "macos": { + "key": "When the limit is reached, the oldest versions are removed first.", + "arguments": [] + } + }, + { + "id": "native.clearing", + "description": "Native UI: Clearing…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clearing…", + "arguments": [] + } + }, + { + "id": "native.clearProjectHistory", + "description": "Native UI: Clear Project History…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear Project History…", + "arguments": [] + } + }, + { + "id": "native.openAProjectToSeeHowMuchSpaceItsHistoryUses", + "description": "Native UI: Open a project to see how much space its history uses.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to see how much space its history uses.", + "arguments": [] + } + }, + { + "id": "native.typeAPathInsideTheProjectToCheckItAgainstTheRulesAboveAsYouEditThemWhetherLocalH0f31e93916", + "description": "Native UI: Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type a path inside the project to check it against the rules above as you edit them: whether local history records the file, and whether Git ignores it. Git ignore rules do not affect local history.", + "arguments": [] + } + }, + { + "id": "native.localHistoryFormat", + "description": "Native UI: Local history: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local history: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.gitIgnored", + "description": "Native UI: Git: ignored", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git: ignored", + "arguments": [] + } + }, + { + "id": "native.gitNotIgnored", + "description": "Native UI: Git: not ignored", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git: not ignored", + "arguments": [] + } + }, + { + "id": "native.limitPerProject", + "description": "Native UI: Limit per project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Limit per project", + "arguments": [] + } + }, + { + "id": "native.localHistoryLimitPerProject", + "description": "Native UI: Local history limit per project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local history limit per project", + "arguments": [] + } + }, + { + "id": "native.lineLldIsNotAValidRegularExpressionAndIsSkipped", + "description": "Native UI: Line %lld is not a valid regular expression and is skipped.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Line %lld is not a valid regular expression and is skipped.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.recorded", + "description": "Native UI: Recorded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Recorded", + "arguments": [] + } + }, + { + "id": "native.hiddenInTheProjectTreeNotRecorded", + "description": "Native UI: Hidden in the project tree, not recorded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hidden in the project tree, not recorded", + "arguments": [] + } + }, + { + "id": "native.ignoredByWildcardRule", + "description": "Native UI: Ignored by wildcard rule %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ignored by wildcard rule %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.ignoredByRegularExpressionLineLld", + "description": "Native UI: Ignored by regular expression line %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ignored by regular expression line %lld", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul16320b202b", + "description": "Native UI: Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local history keeps versions of files as you save and edit them, up to 100 MB per project by default; change the limit next to Used.\nIgnore rules only keep files out of local history; the project tree still shows them. Hidden paths are never recorded.\nWildcard: one pattern per line, using the same syntax as Hidden paths, for example `*.log` or `**/generated/`. The list starts with built-in dependency, cache and build output folders such as `**/node_modules/`; delete a line to record that folder again.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.", + "arguments": [] + } + }, + { + "id": "history.clearFailed", + "description": "Native UI: local history storage clear failure with an explicit retry action.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not clear this project's local history. Check storage permissions and try again.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/history-snapshots.json b/shared/platform-feature-matrix/features/history-snapshots.json index 0c85a6c077..518a21f948 100644 --- a/shared/platform-feature-matrix/features/history-snapshots.json +++ b/shared/platform-feature-matrix/features/history-snapshots.json @@ -9,7 +9,11 @@ "macos/Sources/Lithe/Services", "macos/Sources/LitheLocalHistoryModule", "macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift", - "rust/lithe-core/src/project/history.rs" + "rust/lithe-core/src/project/history.rs", + "macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift", + "macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift", + "macos/Sources/Lithe/Application/Features/LocalHistoryStorageSettingsModel.swift", + "macos/Tests/LitheTests/LocalHistorySettingsTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -24,6 +28,6 @@ "verificationStatus": "pending" }, "owner": "Local History", - "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 macOS 外部变更和目录操作按批记录历史,仅首个成功快照触发全库过期清理;验证跳过文件、连续批次、取消及单文件保存,4000 文件变化后 CPU 回落且历史仍可读取。 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。", - "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;基线跳过 node_modules、.venv、target、.claude 等生成目录和嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。" + "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 macOS 外部变更和目录操作按批记录历史,仅首个成功快照触发全库过期清理;验证跳过文件、连续批次、取消及单文件保存,4000 文件变化后 CPU 回落且历史仍可读取。 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、通配符含内置文件夹、正则)、拒绝越界路径、占用统计与清空项目历史。内置项可改:Rust 测试覆盖内置文件夹以 `**/名称/` 通配符规则生效、命中时报告该规则、`!**/名称/` 或 `!名称` 关闭内置项后重新记录;隐藏路径的 `!` 条目关闭内置隐藏目录。上限可配:Rust 测试覆盖默认 100MB、配置值生效并限定在 10MB–100GB、基线预算为上限一半。Git 对照:Rust 测试覆盖测试路径在 Git 仓库中返回是否被 .gitignore 忽略、非仓库返回空。 PR #1615 回归:关闭内置隐藏项和历史忽略项后,添加无关通配符仍可预览及记录;清空前取消并等待已有保存和外部变更任务,清空代次拒绝在途批次续写,清空后新保存可记录;隐藏路径变化纳入预览请求身份并重新计算结果。 容量输入仅接受完整非负整数与可选 MB 后缀,指数、负数、字母、溢出等无效输入不改变已保存值;回车和失焦均验证恢复或范围限定。清空失败显示中英错误与需再次确认的重试入口,回读剩余用量;重试成功清除错误并显示新用量。", + "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;node_modules、.venv、target、.claude 等依赖、缓存和构建输出文件夹作为内置通配符忽略规则(`**/名称/`),基线和编辑都不记录;基线另外跳过嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。macOS 设置 › 通用 › 本地历史可配置只作用于本地历史的忽略规则(通配符或正则,每行一条,应用后生效),项目树仍显示这些文件;测试路径实时显示被哪条规则忽略,无效正则提示行号并跳过;显示本项目占用/上限,可确认后清空本项目历史。通配符忽略规则和隐藏路径都以文本框列出内置项,删掉一行即关闭该内置项(保存为 `!条目`,后续版本新增的内置项仍会生效);用量按二进制单位显示(100 MB)。每个项目的上限可在「已用」旁用步进器调整(50–5000 MB,默认 100 MB),保存时按新上限淘汰;上限也可直接输入数值;测试路径旁有 ⓘ 说明,结果分两行显示本地历史是否记录、Git 是否忽略(仅作对照,本地历史不跟随 .gitignore)。Windows 暂无忽略规则与上限设置,但 Core 的 history.record 已支持 ignoreRules。" }