From 52641f8f3b7d1f4813c6e80105cd80b37656064d Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 23:39:04 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(core):=20=E6=9C=AC=E5=9C=B0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=AF=8F=E4=B8=AA=E9=A1=B9=E7=9B=AE=E9=99=90=E5=88=B6?= =?UTF-8?q?=20100MB=EF=BC=8C=E5=9F=BA=E7=BA=BF=E8=B7=B3=E8=BF=87=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个项目上限 100MB(按 4KB 分配块计,占用量进程内缓存),其中项目基线最多 50MB - 基线跳过 node_modules、.venv、target、.claude、.uv-cache 等生成目录和嵌套 Git 仓库 - 保存时清理过期条目和旧版本遗留的排除目录基线;超限按基线→编辑→标签由旧到新淘汰 --- rust/lithe-core/src/project/history.rs | 468 +++++++++++++++++- .../features/history-snapshots.json | 10 +- 2 files changed, 454 insertions(+), 24 deletions(-) diff --git a/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index 3e99091b1e..355da55a19 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -4,14 +4,63 @@ use crate::project::files::VisibilityRules; use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; use crate::protocol::{HistoryEntriesResponse, HistoryEntryResponse}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; 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. +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; +/// 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. +/// +/// Like IDEA's excluded roots, these are regenerated by tools rather than +/// edited, yet a single virtual environment holds tens of thousands of files. +/// 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] = &[ + ".artifacts", + ".build", + ".cache", + ".claude", + ".git", + ".gradle", + ".idea", + ".mypy_cache", + ".next", + ".nuxt", + ".pytest_cache", + ".ruff_cache", + ".swiftpm", + ".svn", + ".tox", + ".uv-cache", + ".venv", + ".worktree", + ".worktrees", + "DerivedData", + "Pods", + "__pycache__", + "build", + "coverage", + "design-qa-artifacts", + "dist", + "node_modules", + "out", + "target", + "venv", +]; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -110,6 +159,10 @@ pub fn record(request: HistoryRecordRequest) -> Result content.into_bytes(), None => { @@ -143,6 +196,10 @@ pub fn record(request: HistoryRecordRequest) -> Result= MAX_BASELINE_STORAGE_BYTES { + return Ok(None); + } let directory = storage.join(stable_identifier(&relative_path)); fs::create_dir_all(&directory)?; let entries = read_entries(&directory, &storage); @@ -174,14 +231,17 @@ pub fn record(request: HistoryRecordRequest) -> Result Result<(), CoreError> { )?; } let _ = fs::remove_dir_all(source_directory); + invalidate_storage_usage(&storage); Ok(()) } @@ -309,6 +370,7 @@ pub fn delete(request: HistoryDeleteRequest) -> Result<(), CoreError> { } fs::remove_file(storage.join(entry.content_path)).map_err(CoreError::from)?; fs::remove_file(metadata_path).map_err(CoreError::from)?; + invalidate_storage_usage(&storage); if directory .read_dir() .map(|mut entries| entries.next().is_none()) @@ -403,37 +465,192 @@ fn default_prune_expired() -> bool { } fn trim_entries(directory: &Path, storage: &Path) { + let mut removed = false; for entry in read_entries(directory, storage) .into_iter() .skip(MAX_ENTRIES_PER_FILE) { - let _ = fs::remove_file(storage.join(&entry.content_path)); - let _ = fs::remove_file(directory.join(format!("{}.json", entry.id))); + remove_entry(storage, &entry); + removed = true; + } + if removed { + invalidate_storage_usage(storage); } } +/// Removes expired entries and baselines that current exclusion 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) { let cutoff = unix_seconds() - RETENTION_SECONDS; - let Ok(directories) = fs::read_dir(storage) else { + let mut removed = false; + for entry in all_entries(storage) { + let excluded_baseline = entry.reason == "projectBaseline" + && has_excluded_baseline_component(&entry.relative_path); + if entry.timestamp < cutoff || excluded_baseline { + remove_entry(storage, &entry); + removed = true; + } + } + if removed { + invalidate_storage_usage(storage); + } +} + +/// Evicts snapshots until the storage fits `limit`. +/// +/// Baselines go first because saves already record the text before each edit; +/// labeled snapshots go last because the user named them deliberately. Within +/// each group the oldest snapshot is evicted first. +fn enforce_storage_limit(storage: &Path, limit: u64) { + let mut usage = storage_usage(storage); + if usage <= limit { return; - }; - for directory in directories.filter_map(Result::ok) { - if !directory - .file_type() - .map(|value| value.is_dir()) - .unwrap_or(false) - { - continue; + } + let mut entries = all_entries(storage); + entries.sort_by_key(|entry| { + ( + entry.label.is_some(), + entry.reason != "projectBaseline", + entry.timestamp, + ) + }); + for entry in entries { + if usage <= limit { + break; } - for entry in read_entries(&directory.path(), storage) { - if entry.timestamp < cutoff { - let _ = fs::remove_file(storage.join(&entry.content_path)); - let _ = fs::remove_file(directory.path().join(format!("{}.json", entry.id))); - } + usage = usage.saturating_sub(entry_allocated_bytes(storage, &entry)); + remove_entry(storage, &entry); + } + invalidate_storage_usage(storage); +} + +fn all_entries(storage: &Path) -> Vec { + let Ok(directories) = fs::read_dir(storage) else { + return Vec::new(); + }; + directories + .filter_map(Result::ok) + .filter(|directory| { + directory + .file_type() + .map(|value| value.is_dir()) + .unwrap_or(false) + }) + .flat_map(|directory| read_entries(&directory.path(), storage)) + .collect() +} + +fn remove_entry(storage: &Path, entry: &StoredEntry) { + let _ = fs::remove_file(storage.join(&entry.content_path)); + let _ = fs::remove_file(metadata_path(storage, entry)); + if let Some(directory) = storage.join(&entry.content_path).parent() { + // Only succeeds once the file has no snapshots left. + let _ = fs::remove_dir(directory); + } +} + +fn metadata_path(storage: &Path, entry: &StoredEntry) -> PathBuf { + storage + .join(stable_identifier(&entry.relative_path)) + .join(format!("{}.json", entry.id)) +} + +fn entry_allocated_bytes(storage: &Path, entry: &StoredEntry) -> u64 { + [ + storage.join(&entry.content_path), + metadata_path(storage, entry), + ] + .iter() + .filter_map(|path| fs::metadata(path).ok()) + .map(|metadata| allocated_bytes(metadata.len())) + .sum() +} + +fn allocated_bytes(length: u64) -> u64 { + length.div_ceil(STORAGE_BLOCK_BYTES).max(1) * STORAGE_BLOCK_BYTES +} + +/// Process-wide usage per storage root, so baseline seeding checks the budget +/// without rescanning tens of thousands of snapshot files for every record. +fn usage_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn storage_usage(storage: &Path) -> u64 { + if let Some(value) = usage_cache() + .lock() + .ok() + .and_then(|cache| cache.get(storage).copied()) + { + return value; + } + let value = scan_storage_usage(storage); + if let Ok(mut cache) = usage_cache().lock() { + cache.insert(storage.to_path_buf(), value); + } + value +} + +fn scan_storage_usage(storage: &Path) -> u64 { + let Ok(directories) = fs::read_dir(storage) else { + return 0; + }; + directories + .filter_map(Result::ok) + .filter_map(|directory| fs::read_dir(directory.path()).ok()) + .flatten() + .filter_map(Result::ok) + .filter_map(|file| file.metadata().ok()) + .filter(|metadata| metadata.is_file()) + .map(|metadata| allocated_bytes(metadata.len())) + .sum() +} + +fn adjust_storage_usage(storage: &Path, added: u64) { + if let Ok(mut cache) = usage_cache().lock() { + if let Some(value) = cache.get_mut(storage) { + *value += added; } } } +fn invalidate_storage_usage(storage: &Path) { + if let Ok(mut cache) = usage_cache().lock() { + cache.remove(storage); + } +} + +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. +/// +/// 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(); + components.into_iter().any(|component| { + directory.push(component); + directory.join(".git").exists() + }) +} + fn existing_root(value: &str) -> Result { let path = PathBuf::from(value); let metadata = fs::metadata(&path) @@ -537,3 +754,214 @@ fn parse_iso_timestamp(value: &str) -> i64 { let days = era * 146_097 + day_of_era - 719_468; days * 86_400 + hour * 3_600 + minute * 60 + second } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::support::temporary_root; + + fn record_request( + workspace: &Path, + storage: &Path, + path: &str, + reason: &str, + content: Option<&str>, + ) -> HistoryRecordRequest { + HistoryRecordRequest { + workspace_root: workspace.to_string_lossy().into_owned(), + storage_root: storage.to_string_lossy().into_owned(), + path: path.into(), + reason: reason.into(), + content: content.map(str::to_string), + prune_expired: reason != "projectBaseline", + hidden_directory_names: Vec::new(), + hidden_file_patterns: Vec::new(), + } + } + + fn write_file(root: &Path, path: &str, content: &str) { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, content).unwrap(); + } + + /// Writes an entry directly, as an older release that seeded every file would have. + fn write_stored_entry(storage: &Path, path: &str, reason: &str, timestamp: i64, size: usize) { + let id = format!("{:08x}-0000-0000-0000-{:012x}", timestamp, size); + let directory = storage.join(stable_identifier(path)); + fs::create_dir_all(&directory).unwrap(); + let entry = StoredEntry { + version: HISTORY_VERSION, + id: id.clone(), + timestamp, + relative_path: path.into(), + reason: reason.into(), + content_path: format!("{}/{}.snapshot", stable_identifier(path), id), + byte_count: size, + label: None, + }; + fs::write(storage.join(&entry.content_path), "x".repeat(size)).unwrap(); + fs::write( + directory.join(format!("{id}.json")), + serde_json::to_vec(&entry).unwrap(), + ) + .unwrap(); + } + + fn stored_paths(storage: &Path) -> Vec { + let mut paths = all_entries(storage) + .into_iter() + .map(|entry| entry.relative_path) + .collect::>(); + paths.sort(); + paths + } + + #[test] + fn baseline_skips_generated_directories_and_nested_repositories() { + let root = temporary_root("history-baseline-exclusions"); + let workspace = root.join("workspace"); + let storage = root.join("storage"); + write_file(&workspace, "src/main.py", "print('a')\n"); + write_file(&workspace, "tools/.uv-cache/wheel/site.py", "import os\n"); + write_file(&workspace, ".claude/worktrees/a/src/main.py", "x\n"); + write_file(&workspace, "worktrees/feature/.git", "gitdir: elsewhere\n"); + write_file(&workspace, "worktrees/feature/src/main.py", "print('b')\n"); + + for path in [ + "tools/.uv-cache/wheel/site.py", + ".claude/worktrees/a/src/main.py", + "worktrees/feature/src/main.py", + ] { + let response = record(record_request( + &workspace, + &storage, + path, + "projectBaseline", + None, + )) + .unwrap(); + assert!(response.is_none(), "{path} should not get a baseline"); + } + let response = record(record_request( + &workspace, + &storage, + "src/main.py", + "projectBaseline", + None, + )) + .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()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn saving_prunes_baselines_that_older_releases_kept_for_excluded_paths() { + let root = temporary_root("history-legacy-baselines"); + let workspace = root.join("workspace"); + let storage = root.join("storage"); + fs::create_dir_all(&workspace).unwrap(); + let now = unix_seconds(); + write_stored_entry(&storage, ".venv/lib/site.py", "projectBaseline", now, 10); + write_stored_entry(&storage, ".venv/lib/edited.py", "saved", now, 10); + write_stored_entry(&storage, "src/main.py", "projectBaseline", now, 10); + + record(record_request( + &workspace, + &storage.canonicalize().unwrap(), + "src/other.py", + "saved", + Some("x\n"), + )) + .unwrap(); + + assert_eq!( + stored_paths(&storage.canonicalize().unwrap()), + [".venv/lib/edited.py", "src/main.py", "src/other.py"] + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn storage_limit_evicts_old_baselines_before_edits_and_labels() { + let root = temporary_root("history-storage-limit"); + let storage = root.join("storage"); + fs::create_dir_all(&storage).unwrap(); + let storage = storage.canonicalize().unwrap(); + write_stored_entry(&storage, "a/old-edit.txt", "saved", 100, 10); + write_stored_entry(&storage, "a/old-base.txt", "projectBaseline", 200, 10); + write_stored_entry(&storage, "a/new-base.txt", "projectBaseline", 300, 10); + write_stored_entry(&storage, "a/new-edit.txt", "saved", 400, 10); + let labeled = all_entries(&storage) + .into_iter() + .find(|entry| entry.relative_path == "a/old-edit.txt") + .unwrap(); + fs::write( + metadata_path(&storage, &labeled), + serde_json::to_vec(&StoredEntry { + label: Some("keep".into()), + ..labeled + }) + .unwrap(), + ) + .unwrap(); + invalidate_storage_usage(&storage); + // Each entry occupies two blocks: snapshot plus metadata. + let entry_bytes = 2 * STORAGE_BLOCK_BYTES; + assert_eq!(storage_usage(&storage), 4 * entry_bytes); + + enforce_storage_limit(&storage, 2 * entry_bytes); + assert_eq!(stored_paths(&storage), ["a/new-edit.txt", "a/old-edit.txt"]); + + enforce_storage_limit(&storage, entry_bytes); + assert_eq!(stored_paths(&storage), ["a/old-edit.txt"]); + assert_eq!(storage_usage(&storage), entry_bytes); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn baseline_stops_once_its_budget_is_used() { + let root = temporary_root("history-baseline-budget"); + let workspace = root.join("workspace"); + let storage = root.join("storage"); + write_file(&workspace, "src/main.py", "print('a')\n"); + fs::create_dir_all(&storage).unwrap(); + let canonical = storage.canonicalize().unwrap(); + usage_cache() + .lock() + .unwrap() + .insert(canonical.clone(), MAX_BASELINE_STORAGE_BYTES); + + let baseline = record(record_request( + &workspace, + &storage, + "src/main.py", + "projectBaseline", + None, + )) + .unwrap(); + assert!(baseline.is_none()); + let saved = record(record_request( + &workspace, + &storage, + "src/main.py", + "saved", + Some("print('b')\n"), + )) + .unwrap(); + assert!(saved.is_some()); + invalidate_storage_usage(&canonical); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/shared/platform-feature-matrix/features/history-snapshots.json b/shared/platform-feature-matrix/features/history-snapshots.json index 1860284eba..58cb1ce823 100644 --- a/shared/platform-feature-matrix/features/history-snapshots.json +++ b/shared/platform-feature-matrix/features/history-snapshots.json @@ -6,7 +6,8 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/History", - "macos/Sources/Lithe/Services" + "macos/Sources/Lithe/Services", + "rust/lithe-core/src/project/history.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -15,12 +16,13 @@ "evidence": [ "windows/tauri/src/features/local-history", "windows/tauri/src/features/local-history/components/project-local-history.tsx", - "windows/tauri/src/features/local-history/utils/history-metadata.ts" + "windows/tauri/src/features/local-history/utils/history-metadata.ts", + "rust/lithe-core/src/project/history.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Local History", - "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。", - "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。" + "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。", + "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;基线跳过 node_modules、.venv、target、.claude 等生成目录和嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。" } From 17ac1d175999c27f93cc73f2b6bdcd20ed086523 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 10:33:47 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(macos):=20=E6=9C=AC=E5=9C=B0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=BF=BD=E7=95=A5=E8=A7=84=E5=88=99=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=EF=BC=88=E9=80=9A=E9=85=8D=E7=AC=A6/=E6=AD=A3=E5=88=99?= =?UTF-8?q?=EF=BC=89=E3=80=81=E7=94=A8=E9=87=8F=E6=98=BE=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust Core:history.record 支持 ignoreRules(通配符 + 不区分大小写正则),新增 history.ignorePreview / history.usage / history.clear - 设置 › 通用 › 本地历史:显示 已用 X / 100 MB,可清空当前项目历史(二次确认); 通配符/正则两种规则草稿编辑,测试路径实时预览命中规则,无效正则逐行提示, 点“应用”才保存 - 补齐中英词条、契约文档、功能矩阵与集成测试 --- macos/Resources/en.lproj/Localizable.strings | 42 ++ .../zh-Hans.lproj/Localizable.strings | 42 ++ .../WorkspaceProjectionComposition.swift | 7 +- .../Lithe/Core/Rust/RustCoreBridge.swift | 85 +++- .../Rust/RustLocalHistoryOperations.swift | 26 +- .../AppModel/AppModel+HistoryModule.swift | 15 + .../Lithe/Models/AppModel/AppModel.swift | 10 +- .../Bridges/FileVisibilityRules+App.swift | 13 + .../Lithe/Models/Settings/AppSettings.swift | 21 + .../App/LocalHistorySettingsControl.swift | 202 ++++++++++ .../Views/App/SettingsSearchVocabulary.swift | 2 +- .../Lithe/Views/App/SettingsView.swift | 4 + .../ProjectHistoryFeatureModel.swift | 31 ++ .../Ports/LocalHistoryPorts.swift | 43 +- .../Services/LocalHistoryService.swift | 8 + .../LocalHistoryModuleTests.swift | 3 + .../LocalHistoryIgnoreRulesTests.swift | 51 +++ rust/lithe-core/src/project/history.rs | 367 +++++++++++++++++- rust/lithe-core/src/protocol/command.rs | 9 + rust/lithe-core/src/runtime/dispatcher.rs | 50 ++- shared/contracts/rust-core-api.md | 17 + shared/i18n/locales/en-US.json | 23 +- shared/i18n/locales/zh-CN.json | 23 +- shared/i18n/messages.json | 251 ++++++++++++ .../features/history-snapshots.json | 7 +- 25 files changed, 1334 insertions(+), 18 deletions(-) create mode 100644 macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift create mode 100644 macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 0c27994274..191d0567d8 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -684,6 +684,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?"; @@ -698,7 +699,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: %@"; @@ -1414,6 +1417,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"; @@ -1691,6 +1695,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"; @@ -1756,6 +1761,8 @@ "If: %@" = "If: %@"; "Ignore whitespace" = "Ignore whitespace"; "Ignored" = "Ignored"; +"Ignored by regular expression line %lld" = "Ignored by regular expression line %lld"; +"Ignored by wildcard line %lld" = "Ignored by wildcard line %lld"; "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"; @@ -1771,6 +1778,7 @@ "Import skill" = "Import skill"; "Import skill directory" = "Import skill directory"; "Import source" = "Import source"; +"In %@: no initial copy, edits are recorded" = "In %@: no initial copy, edits are recorded"; "In effect: " = "In effect: "; "Include" = "Include"; "Include a short body when useful" = "Include a short body when useful"; @@ -1981,6 +1989,7 @@ "Lifecycle" = "Lifecycle"; "Light" = "Light"; "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"; @@ -2092,6 +2101,8 @@ "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.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded." = "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded."; "Local history work is still active." = "Local history work is still active."; "Location" = "Location"; "Lock Worktree" = "Lock Worktree"; @@ -2408,6 +2419,7 @@ "No history recorded yet" = "No history recorded yet"; "No host yet" = "No host yet"; "No implementations found" = "No implementations found"; +"No initial copy" = "No initial copy"; "No installed plugin supports %@ yet." = "No installed plugin supports %@ yet."; "No items" = "No items"; "No language dependency sources registered" = "No language dependency sources registered"; @@ -2608,6 +2620,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."; @@ -2961,6 +2974,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"; @@ -2995,6 +3009,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"; @@ -3790,6 +3806,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"; @@ -4466,6 +4483,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"; @@ -4554,6 +4572,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?"; @@ -4561,6 +4580,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."; @@ -5330,13 +5351,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."; @@ -6073,6 +6097,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"; @@ -6337,6 +6362,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"; @@ -6408,6 +6434,8 @@ "native.ifResetUse" = "If reset, use"; "native.ignoreWhitespace" = "Ignore whitespace"; "native.ignored" = "Ignored"; +"native.ignoredByRegularExpressionLineLld" = "Ignored by regular expression line %1$lld"; +"native.ignoredByWildcardLineLld" = "Ignored by wildcard line %1$lld"; "native.ignoredEntriesLld" = "Ignored entries: %1$lld"; "native.imageZoom" = "Image zoom"; "native.imagesCanOnlyBePastedIntoMarkdownDocuments" = "Images can only be pasted into Markdown documents"; @@ -6425,6 +6453,7 @@ "native.importSource" = "Import source"; "native.in" = "%1$@ in %2$@"; "native.inEffect" = "In effect: "; +"native.inNoInitialCopyEditsAreRecorded" = "In %1$@: no initial copy, edits are recorded"; "native.include" = "Include"; "native.includeAShortBodyWhenUseful" = "Include a short body when useful"; "native.includeInCommit" = "Include in commit"; @@ -6708,6 +6737,7 @@ "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."; @@ -6865,7 +6895,9 @@ "native.localEditsToTheseFilesAreLost" = "Local edits to these files are lost."; "native.localHistory" = "Local History"; "native.localHistory.variant2" = "Local History…"; +"native.localHistory.variant3" = "Local history"; "native.localHistoryIsAvailableForTextFiles" = "Local History is available for text files"; +"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0" = "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded."; "native.localHistoryWorkIsStillActive" = "Local history work is still active."; "native.localJDKHomeOptional" = "Local JDK Home (optional)"; "native.localPackage" = "Local Package"; @@ -7174,6 +7206,7 @@ "native.noHooksYet" = "No Hooks yet."; "native.noHostYet" = "No host yet"; "native.noImplementationsFound" = "No implementations found"; +"native.noInitialCopy" = "No initial copy"; "native.noInstalledPluginSupportsYet" = "No installed plugin supports %1$@ yet."; "native.noItems" = "No items"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory."; @@ -7368,6 +7401,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."; @@ -7735,6 +7769,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"; @@ -7767,6 +7802,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"; @@ -8523,6 +8560,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."; @@ -9150,6 +9188,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."; @@ -9248,6 +9287,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?"; @@ -9255,6 +9295,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 0ab83aa705..a93c3b628f 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -684,6 +684,7 @@ "Clear Git console" = "清空 Git 控制台"; "Clear Logs" = "清空日志"; "Clear Override" = "清除覆盖"; +"Clear Project History…" = "清空项目历史…"; "Clear branch filter" = "清除分支筛选"; "Clear build output" = "清除构建输出"; "Clear collected logs?" = "清空已收集的日志?"; @@ -698,7 +699,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: %@" = "客户端:%@"; @@ -1414,6 +1417,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" = "异常断点"; @@ -1691,6 +1695,7 @@ "Headers" = "请求头"; "Heap Memory" = "堆内存"; "Hexadecimal" = "十六进制"; +"Hidden in the project tree, not recorded" = "已在项目树中隐藏,不记录"; "Hidden paths" = "隐藏路径"; "Hide %@ tool window" = "隐藏 %@ 工具窗口"; "Hide API key" = "隐藏密钥"; @@ -1756,6 +1761,8 @@ "If: %@" = "条件:%@"; "Ignore whitespace" = "忽略空白"; "Ignored" = "已忽略"; +"Ignored by regular expression line %lld" = "被正则表达式第 %lld 行忽略"; +"Ignored by wildcard line %lld" = "被通配符第 %lld 行忽略"; "Ignored entries: %lld" = "已忽略条目:%lld"; "Image zoom" = "图片缩放"; "Images can only be pasted into Markdown documents" = "图片只能粘贴到 Markdown 文档中"; @@ -1771,6 +1778,7 @@ "Import skill" = "导入技能"; "Import skill directory" = "导入技能目录"; "Import source" = "导入来源"; +"In %@: no initial copy, edits are recorded" = "位于 %@:不做初始副本,编辑仍会记录"; "In effect: " = "生效:"; "Include" = "包含范围"; "Include a short body when useful" = "有必要时包含简短正文"; @@ -1981,6 +1989,7 @@ "Lifecycle" = "生命周期"; "Light" = "浅色"; "Line" = "行"; +"Line %lld is not a valid regular expression and is skipped." = "第 %lld 行不是有效的正则表达式,已跳过。"; "Line Breakpoints" = "行断点"; "Line breakpoint actions" = "行断点操作"; "Line numbers" = "行号"; @@ -2092,6 +2101,8 @@ "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.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded." = "本地历史会在你保存和编辑时保留文件版本,每个项目最多 100 MB。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n「不做初始副本」中的文件夹里的文件,打开项目时不会复制,编辑仍会记录。"; "Local history work is still active." = "本地历史任务仍在进行。"; "Location" = "位置"; "Lock Worktree" = "锁定工作树"; @@ -2408,6 +2419,7 @@ "No history recorded yet" = "还没有历史记录"; "No host yet" = "尚未填写主机"; "No implementations found" = "没有找到实现"; +"No initial copy" = "不做初始副本"; "No installed plugin supports %@ yet." = "尚未安装支持 %@ 的插件。"; "No items" = "暂无项目"; "No language dependency sources registered" = "未注册语言依赖来源"; @@ -2608,6 +2620,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." = "运行“当前文件”前请先打开一个源文件。"; @@ -2961,6 +2974,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"; @@ -2995,6 +3009,8 @@ "Registration" = "注册状态"; "Registry website" = "Registry 网站"; "Regular Expression" = "正则表达式"; +"Regular expression" = "正则表达式"; +"Regular expression ignore rules" = "正则表达式忽略规则"; "Reinstall" = "重新安装"; "Relative" = "相对行号"; "Release Date" = "发布日期"; @@ -3790,6 +3806,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" = "测试"; @@ -4466,6 +4483,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 个工具"; @@ -4554,6 +4572,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?" = "应用范围"; @@ -4561,6 +4580,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 会改为询问你。"; @@ -5330,13 +5351,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" = "点击折叠参数可展开;右键查看命令详情。"; @@ -6073,6 +6097,7 @@ "native.evaluateExpression" = "计算表达式"; "native.evaluateExpressionWhilePaused" = "暂停时计算表达式"; "native.evaluating" = "正在计算…"; +"native.everySavedVersionOfThisProjectSFilesIsDeletedOpenFilesAreNotChanged" = "此项目文件的所有已保存版本都会被删除,已打开的文件不受影响。"; "native.example" = "示例"; "native.exception" = "异常"; "native.exceptionBreakpoints" = "异常断点"; @@ -6337,6 +6362,7 @@ "native.header" = "请求头"; "native.headers" = "请求头"; "native.heapMemory" = "堆内存"; +"native.hiddenInTheProjectTreeNotRecorded" = "已在项目树中隐藏,不记录"; "native.hiddenPaths" = "隐藏路径"; "native.hideAPIKey" = "隐藏密钥"; "native.hideBranches" = "隐藏分支"; @@ -6408,6 +6434,8 @@ "native.ifResetUse" = "重置后使用"; "native.ignoreWhitespace" = "忽略空白"; "native.ignored" = "已忽略"; +"native.ignoredByRegularExpressionLineLld" = "被正则表达式第 %1$lld 行忽略"; +"native.ignoredByWildcardLineLld" = "被通配符第 %1$lld 行忽略"; "native.ignoredEntriesLld" = "已忽略条目:%1$lld"; "native.imageZoom" = "图片缩放"; "native.imagesCanOnlyBePastedIntoMarkdownDocuments" = "图片只能粘贴到 Markdown 文档中"; @@ -6425,6 +6453,7 @@ "native.importSource" = "导入来源"; "native.in" = "%1$@,位于 %2$@"; "native.inEffect" = "生效:"; +"native.inNoInitialCopyEditsAreRecorded" = "位于 %1$@:不做初始副本,编辑仍会记录"; "native.include" = "包含范围"; "native.includeAShortBodyWhenUseful" = "有必要时包含简短正文"; "native.includeInCommit" = "包含在提交中"; @@ -6708,6 +6737,7 @@ "native.lineBreakpoints" = "行断点"; "native.lineColumn" = "[行] [:列]:"; "native.lineLld" = "%1$@,第 %2$lld 行,%3$@"; +"native.lineLldIsNotAValidRegularExpressionAndIsSkipped" = "第 %1$lld 行不是有效的正则表达式,已跳过。"; "native.linear" = "Linear"; "native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts" = "行号和列号从 1 开始,范围的终点不能早于起点。"; "native.linesKeptForScrollingBackInNewTerminalSessions" = "新建终端会话保留的可回滚行数。"; @@ -6865,7 +6895,9 @@ "native.localEditsToTheseFilesAreLost" = "这些文件的本地修改将会丢失。"; "native.localHistory" = "本地历史"; "native.localHistory.variant2" = "本地历史…"; +"native.localHistory.variant3" = "本地历史"; "native.localHistoryIsAvailableForTextFiles" = "本地历史仅适用于文本文件"; +"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0" = "本地历史会在你保存和编辑时保留文件版本,每个项目最多 100 MB。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n「不做初始副本」中的文件夹里的文件,打开项目时不会复制,编辑仍会记录。"; "native.localHistoryWorkIsStillActive" = "本地历史任务仍在进行。"; "native.localJDKHomeOptional" = "本地 JDK 主目录(可选)"; "native.localPackage" = "本地插件包"; @@ -7174,6 +7206,7 @@ "native.noHooksYet" = "还没有 Hook。"; "native.noHostYet" = "尚未填写主机"; "native.noImplementationsFound" = "没有找到实现"; +"native.noInitialCopy" = "不做初始副本"; "native.noInstalledPluginSupportsYet" = "尚未安装支持 %1$@ 的插件。"; "native.noItems" = "暂无项目"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。"; @@ -7368,6 +7401,7 @@ "native.openAProjectToConfigureMCPAccessForAgents" = "打开项目后,可在这里配置 Agent 的 MCP 访问权限。"; "native.openAProjectToLetConnectedAgentsInspectAndControlIt" = "打开项目后,已连接的 Agent 才能检查和控制它。"; "native.openAProjectToManageItsSkills" = "请先打开项目,再管理项目技能。"; +"native.openAProjectToSeeHowMuchSpaceItsHistoryUses" = "打开项目后可查看其历史占用的空间。"; "native.openAProjectToSeeItsLanguageService" = "打开项目后可查看其语言服务。"; "native.openAProjectToUseGit" = "请打开项目以使用 Git"; "native.openASourceFileBeforeRunningCurrentFile" = "运行“当前文件”前请先打开一个源文件。"; @@ -7735,6 +7769,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"; @@ -7767,6 +7802,8 @@ "native.registration" = "注册状态"; "native.registryWebsite" = "Registry 网站"; "native.regularExpression" = "正则表达式"; +"native.regularExpression.variant2" = "正则表达式"; +"native.regularExpressionIgnoreRules" = "正则表达式忽略规则"; "native.releaseDate" = "发布日期"; "native.releaseNote" = "发布说明"; "native.releaseNotes" = "发行说明"; @@ -8523,6 +8560,7 @@ "native.test" = "测试"; "native.testConnection" = "测试连接"; "native.testJava" = "测试代码"; +"native.testPath" = "测试路径"; "native.testProcessTreeResidentMemory" = "测试进程树 · 驻留内存"; "native.testResources" = "测试资源"; "native.testRunTimedOutAfterSeconds" = "%1$@ 测试运行在 %2$@ 秒后超时。"; @@ -9150,6 +9188,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;单次操作可以覆盖这些默认值。"; @@ -9248,6 +9287,7 @@ "native.whenQuittingLithe" = "退出 Lithe 时"; "native.whenShouldThisSkillBeUsed" = "什么情况下应使用这个技能?"; "native.whenSubmittingAPrompt" = "提交提示词时"; +"native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst" = "达到上限时,最早的版本会先被删除。"; "native.whenToRun" = "何时运行"; "native.wherePushGoesWhenYouDoNotChooseATargetExplicitly" = "未明确选择目标时,Push 会推送到哪里。"; "native.whereShouldThisApply" = "应用范围"; @@ -9255,6 +9295,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/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 77dc9e3aa8..c41b1d9f77 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -262,6 +262,47 @@ 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 baselineExcluded(directory: String) + case pattern(index: Int) + case regex(index: Int) + + private enum CodingKeys: String, CodingKey { case kind, directory, 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 "baselineExcluded": self = .baselineExcluded(directory: try container.decode(String.self, forKey: .directory)) + case "pattern": self = .pattern(index: try container.decode(Int.self, forKey: .index)) + 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 baselineExcludedDirectoryNames: [String] + let matched: Match? + } + + private struct HistoryClearPayload: Decodable, Sendable { + let cleared: Bool + } + struct MarkdownRenderPayload: Decodable, Sendable { let html: String } @@ -1603,6 +1644,21 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let pruneExpired: Bool let hiddenDirectoryNames: [String] let hiddenFilePatterns: [String] + let ignoreRules: HistoryIgnoreRules + } + + private struct HistoryIgnoreRules: Encodable { + let pathPatterns: [String] + let pathRegexes: [String] + } + + private struct HistoryIgnorePreviewRequest: Encodable { + let path: String? + let ignoreRules: HistoryIgnoreRules + } + + private struct HistoryStorageRequest: Encodable { + let storageRoot: String } private struct HistoryEntriesRequest: Encodable { @@ -2678,7 +2734,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { content: String?, pruneExpired: Bool, hiddenDirectoryNames: [String], - hiddenFilePatterns: [String] + hiddenFilePatterns: [String], + ignorePatterns: [String] = [], + ignoreRegexes: [String] = [] ) -> HistoryEntryPayload? { execute( command: "history.record", @@ -2690,11 +2748,34 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { content: content, pruneExpired: pruneExpired, hiddenDirectoryNames: hiddenDirectoryNames, - hiddenFilePatterns: hiddenFilePatterns + hiddenFilePatterns: hiddenFilePatterns, + ignoreRules: HistoryIgnoreRules(pathPatterns: ignorePatterns, pathRegexes: ignoreRegexes) ) ) } + func historyIgnorePreview(path: String?, ignorePatterns: [String], ignoreRegexes: [String]) -> HistoryIgnorePreviewPayload? { + execute( + command: "history.ignorePreview", + payload: HistoryIgnorePreviewRequest( + path: path, + ignoreRules: HistoryIgnoreRules(pathPatterns: ignorePatterns, pathRegexes: ignoreRegexes) + ) + ) + } + + func historyUsage(storageURL: URL) -> HistoryUsagePayload? { + execute(command: "history.usage", payload: HistoryStorageRequest(storageRoot: storageURL.standardizedFileURL.path)) + } + + 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..f0f4acf24a 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift @@ -9,7 +9,9 @@ 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 ).map(Self.makePayload) } @@ -24,6 +26,28 @@ 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) -> LocalHistoryUsage? { + core.historyUsage(storageURL: storageURL).map { LocalHistoryUsage(usedBytes: $0.usedBytes, limitBytes: $0.limitBytes) } + } + + func clear(at storageURL: URL) -> Bool { core.historyClear(storageURL: storageURL) } + + func ignorePreview(path: String?, ignorePatterns: [String], ignoreRegexes: [String]) -> LocalHistoryIgnorePreview? { + guard let value = core.historyIgnorePreview(path: path, ignorePatterns: ignorePatterns, ignoreRegexes: ignoreRegexes) else { return nil } + let matched: LocalHistoryIgnoreMatch? = switch value.matched { + case nil: nil + case .hidden: .hidden + case .baselineExcluded(let directory): .baselineExcluded(directory: directory) + case .pattern(let index): .pattern(index: index) + case .regex(let index): .regex(index: index) + } + return LocalHistoryIgnorePreview( + regexErrors: Dictionary(value.regexErrors.map { ($0.index, $0.message) }, uniquingKeysWith: { first, _ in first }), + baselineExcludedDirectoryNames: value.baselineExcludedDirectoryNames, + matched: matched + ) + } + 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..85dd476d7c 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?, patterns: [String], regexes: [String]) async -> LocalHistoryIgnorePreview? { + await activateHistoryModule()?.previewIgnoreRules(path: path, patterns: patterns, regexes: regexes) + } + + /// 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..2e1f4b6452 100644 --- a/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift +++ b/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift @@ -26,3 +26,16 @@ 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 + ) + } +} diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index bde33e35e0..e82897c0a6 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -49,6 +49,8 @@ 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 gitExecutable = "settings.gitExecutable" static let gitUseCredentialHelper = "settings.gitUseCredentialHelper" static let gitFetchOptions = "settings.gitFetchOptions" @@ -194,6 +196,19 @@ final class AppSettings: ObservableObject { if oldValue != searchExclusionsEnabled { searchExclusionsDidChange.send() } } } + /// Kept out of local history only; the project tree still shows these files. + @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() } + } + } let gitExecutionPreferences = GitExecutionPreferences() @Published var gitExecutable: String { didSet { defaults.set(gitExecutable, forKey: Key.gitExecutable); updateGitExecutionPreferences() } @@ -457,6 +472,8 @@ 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) ?? [] gitExecutable = defaults.string(forKey: Key.gitExecutable) ?? "" gitUseCredentialHelper = defaults.object(forKey: Key.gitUseCredentialHelper) as? Bool ?? true var savedFetchOptions = defaults.data(forKey: Key.gitFetchOptions) @@ -609,6 +626,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 +683,8 @@ final class AppSettings: ObservableObject { hiddenPathPatterns = [] searchExcludedPathPatterns = [] searchExclusionsEnabled = true + localHistoryIgnorePatterns = [] + localHistoryIgnoreRegexes = [] gitFetchOptions = GitFetchOptions() gitExecutable = "" gitUseCredentialHelper = true diff --git a/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift new file mode 100644 index 0000000000..ddde9c6d46 --- /dev/null +++ b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift @@ -0,0 +1,202 @@ +import LitheLocalHistoryModule +import LitheModuleAPI +import SwiftUI + +/// 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 = "" + @State private var testPath = "" + @State private var preview: LocalHistoryIgnorePreview? + @State private var usage: LocalHistoryUsage? + @State private var isConfirmingClear = false + @State private var isClearing = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + storageRow + LitheSettingsSegmentedControl(selection: $kind, options: [.wildcard, .regex], width: 220) { + $0 == .wildcard ? "Wildcard" : "Regular expression" + } + LitheTextEditor(text: kind == .wildcard ? $patternsDraft : $regexesDraft, height: 72) + .accessibilityLabel(kind == .wildcard ? Text("Wildcard ignore rules") : Text("Regular expression ignore rules")) + regexErrors + HStack(spacing: 6) { + LitheTextField(verbatim: "src/generated/Api.java", text: $testPath) + .frame(maxWidth: 260) + .accessibilityLabel(Text("Test path")) + testResult + } + HStack(spacing: 6) { + baselinePills + 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 { usage = await model.localHistoryUsage() } + .task(id: 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: testPath.trimmingCharacters(in: .whitespaces), + patterns: lines(patternsDraft), + regexes: lines(regexesDraft) + ) + if !Task.isCancelled { preview = result } + } + .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 { + Text("Used") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + LitheSettingsPill(verbatim: "\(Self.bytes(usage.usedBytes)) / \(Self.bytes(usage.limitBytes))", kind: .value) + .help(Text("When the limit is reached, the oldest versions are removed first.")) + Spacer(minLength: 8) + Button { isConfirmingClear = true } label: { + isClearing ? Text("Clearing…") : Text("Clear Project History…") + } + .buttonStyle(LitheActionButtonStyle()) + .disabled(isClearing || usage.usedBytes == 0) + } else { + Text("Open a project to see how much space its history uses.") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.tertiaryText) + } + } + } + + private func clear() { + isClearing = true + Task { + _ = await model.clearLocalHistory() + usage = await model.localHistoryUsage() + isClearing = false + } + } + + // MARK: 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 || preview.matched.map(Self.isBaselineOnly) == true + Label(resultText(preview.matched), systemImage: recorded ? "checkmark.circle" : "nosign") + .font(LitheTheme.smallFont) + .foregroundStyle(recorded ? LitheTheme.success : LitheTheme.warning) + .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 .baselineExcluded(let directory): + String(format: L10n.string("In %@: no initial copy, edits are recorded"), directory) + case .pattern(let index): + String(format: L10n.string("Ignored by wildcard line %lld"), index + 1) + case .regex(let index): + String(format: L10n.string("Ignored by regular expression line %lld"), index + 1) + } + } + + private static func isBaselineOnly(_ match: LocalHistoryIgnoreMatch) -> Bool { + if case .baselineExcluded = match { return true } + return false + } + + /// Generated folders get no initial copy when a project opens; edits inside them are still recorded. + @ViewBuilder + private var baselinePills: some View { + let names = preview?.baselineExcludedDirectoryNames ?? [] + if !names.isEmpty { + let shown = names.prefix(3) + HStack(spacing: 6) { + Text("No initial copy") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + ForEach(Array(shown), id: \.self) { LitheSettingsPill(verbatim: $0, kind: .value) } + if names.count > shown.count { + LitheSettingsPill(verbatim: "+\(names.count - shown.count)") + } + } + .help(Text(verbatim: names.joined(separator: " "))) + } + } + + private var previewKey: String { [testPath, patternsDraft, regexesDraft].joined(separator: "\u{0}") } + + private var hasChanges: Bool { + entries(patternsDraft) != settings.localHistoryIgnorePatterns + || entries(regexesDraft) != settings.localHistoryIgnoreRegexes + } + + private func syncDrafts() { + patternsDraft = settings.localHistoryIgnorePatterns.joined(separator: "\n") + regexesDraft = settings.localHistoryIgnoreRegexes.joined(separator: "\n") + } + + private func apply() { + settings.localHistoryIgnorePatterns = entries(patternsDraft) + 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 { + ByteCountFormatter.string(fromByteCount: count, countStyle: .file) + } +} diff --git a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift index eff7e4c78d..02129bb81f 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", "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 e5314705c5..d45d97c915 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -552,6 +552,10 @@ 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.") { hiddenPathsEditor } + + group("Local history", info: "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded.") { + LocalHistorySettingsControl(settings: settings) + } } } diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index d04bf08d7a..c3631c320c 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -99,6 +99,37 @@ public final class ProjectHistoryFeatureModel: ObservableObject { await localHistoryService?.updateVisibilityRules(rules) } + /// Validates ignore rules for the settings page; needs no open project. + public func previewIgnoreRules(path: String?, patterns: [String], regexes: [String]) async -> LocalHistoryIgnorePreview? { + let operations = localHistoryOperations + return await Task.detached { + operations.ignorePreview(path: path, ignorePatterns: patterns, ignoreRegexes: regexes) + }.value + } + + /// The open project's history size, or nil without a project. + public func storageUsage() async -> LocalHistoryUsage? { + await localHistoryService?.usage() + } + + /// Deletes every snapshot of the open project. A running baseline stops + /// first so it cannot refill the storage being cleared. + public func clearHistory() async -> Bool { + guard let localHistoryService else { return false } + seedTask?.cancel() + seedTask = nil + 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..98de1b38eb 100644 --- a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift +++ b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift @@ -3,9 +3,47 @@ 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. + public let ignorePatterns: [String] + public let ignoreRegexes: [String] + public init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String], + ignorePatterns: [String] = [], ignoreRegexes: [String] = []) { self.hiddenDirectoryNames = hiddenDirectoryNames self.hiddenFilePatterns = hiddenFilePatterns + self.ignorePatterns = ignorePatterns + self.ignoreRegexes = ignoreRegexes + } +} + +/// 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 + /// Inside a generated folder: no baseline is taken, but edits are still recorded. + case baselineExcluded(directory: String) + case pattern(index: Int) + 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] + public let baselineExcludedDirectoryNames: [String] + public let matched: LocalHistoryIgnoreMatch? + public init(regexErrors: [Int: String], baselineExcludedDirectoryNames: [String], matched: LocalHistoryIgnoreMatch?) { + self.regexErrors = regexErrors + self.baselineExcludedDirectoryNames = baselineExcludedDirectoryNames + self.matched = matched } } @@ -32,6 +70,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) -> LocalHistoryUsage? + func clear(at storageURL: URL) -> Bool + func ignorePreview(path: String?, ignorePatterns: [String], ignoreRegexes: [String]) -> LocalHistoryIgnorePreview? } public protocol LocalHistoryWorkspaceAccess: Sendable { diff --git a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift index 80f0e05465..765c61a505 100644 --- a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift +++ b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift @@ -26,6 +26,14 @@ actor LocalHistoryService { visibilityRules = rules } + func usage() -> LocalHistoryUsage? { + operations.usage(at: storageURL) + } + + func clear() -> Bool { + operations.clear(at: storageURL) + } + func seed(files: [URL]) async { for fileURL in files where !Task.isCancelled { _ = try? recordFile(at: fileURL, reason: .projectBaseline, pruneExpired: false) diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index 7c579879e0..4ff282b966 100644 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -72,4 +72,7 @@ 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) -> LocalHistoryUsage? { nil } + func clear(at storageURL: URL) -> Bool { false } + func ignorePreview(path: String?, ignorePatterns: [String], ignoreRegexes: [String]) -> LocalHistoryIgnorePreview? { nil } } diff --git a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift new file mode 100644 index 0000000000..b3a281dc7e --- /dev/null +++ b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift @@ -0,0 +1,51 @@ +import Foundation +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()) + + @Test + func previewReportsInvalidRegexLinesAndTheMatchingRule() throws { + let preview = try #require(operations.ignorePreview( + path: "src/Generated/Api.java", + ignorePatterns: ["*.log", ""], + ignoreRegexes: ["", "(unclosed", "generated/"] + )) + #expect(Array(preview.regexErrors.keys) == [1]) + #expect(preview.matched == .regex(index: 2)) + #expect(preview.baselineExcludedDirectoryNames.contains("Pods")) + + #expect(operations.ignorePreview(path: "logs/run.log", ignorePatterns: ["*.log"], ignoreRegexes: [])?.matched == .pattern(index: 0)) + #expect(operations.ignorePreview(path: "ios/Pods/A/a.h", ignorePatterns: [], ignoreRegexes: [])?.matched == .baselineExcluded(directory: "Pods")) + #expect(operations.ignorePreview(path: ".git/config", ignorePatterns: [], ignoreRegexes: [])?.matched == .hidden) + #expect(operations.ignorePreview(path: "src/Main.java", ignorePatterns: [], ignoreRegexes: [])?.matched == nil) + } + + @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)).usedBytes > 0) + + #expect(operations.clear(at: storage)) + #expect(operations.usage(at: storage) == LocalHistoryUsage(usedBytes: 0, limitBytes: 100 * 1024 * 1024)) + #expect(FileManager.default.fileExists(atPath: storage.path)) + } +} diff --git a/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index 355da55a19..b6798b9070 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; const MAX_FILE_SIZE: usize = 2 * 1024 * 1024; @@ -78,6 +78,83 @@ pub struct HistoryRecordRequest { pub hidden_directory_names: Vec, #[serde(default)] pub hidden_file_patterns: Vec, + #[serde(default)] + pub ignore_rules: HistoryIgnoreRules, +} + +/// User 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. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryIgnoreRules { + #[serde(default)] + pub path_patterns: Vec, + #[serde(default)] + pub path_regexes: Vec, +} + +#[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, +} + +#[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, + /// Inside a generated directory: no baseline is taken, edits are recorded. + BaselineExcluded { + directory: String, + }, + Pattern { + index: usize, + }, + 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, + pub baseline_excluded_directory_names: Vec, + pub matched: 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, +} + +#[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 +233,9 @@ 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 = match request.path.as_deref().map(str::trim) { + Some(path) if !path.is_empty() => { + let relative = safe_relative_path(path)?; + preview_match(&request.ignore_rules, &relative) + } + _ => None, + }; + Ok(HistoryIgnorePreviewResponse { + regex_errors, + baseline_excluded_directory_names: BASELINE_EXCLUDED_DIRECTORY_NAMES + .iter() + .map(|name| (*name).to_string()) + .collect(), + matched, + }) +} + +/// Reports one project's allocated history size, rescanning the disk. +pub fn usage(request: HistoryStorageRequest) -> Result { + let storage = storage_root(&request.storage_root)?; + invalidate_storage_usage(&storage); + Ok(HistoryUsageResponse { + used_bytes: storage_usage(&storage), + limit_bytes: MAX_STORAGE_BYTES, + }) +} + +/// 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 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, relative_path: &str) -> Option { + if VisibilityRules::new(Vec::new(), Vec::new()).is_hidden(relative_path, false) { + return Some(HistoryIgnoreMatch::Hidden); + } + if let Some(found) = ignored_rule(rules, relative_path) { + return Some(found); + } + let mut components = relative_path.split('/').collect::>(); + components.pop(); + components.into_iter().find_map(|component| { + BASELINE_EXCLUDED_DIRECTORY_NAMES + .iter() + .find(|name| component.eq_ignore_ascii_case(name)) + .map(|name| HistoryIgnoreMatch::BaselineExcluded { + directory: (*name).to_string(), + }) + }) +} + +/// The first user rule that matches, globs before regexes. +fn ignored_rule(rules: &HistoryIgnoreRules, relative_path: &str) -> Option { + if !rules.path_patterns.is_empty() { + // Built-in hidden paths are already rejected, so a hit here comes from + // the user's own pattern. + let index = rules.path_patterns.iter().position(|pattern| { + !pattern.trim().is_empty() + && VisibilityRules::new(Vec::new(), vec![pattern.clone()]) + .is_hidden(relative_path, false) + }); + if let Some(index) = index { + return Some(HistoryIgnoreMatch::Pattern { index }); + } + } + 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 { @@ -776,6 +996,14 @@ mod tests { prune_expired: reason != "projectBaseline", hidden_directory_names: Vec::new(), hidden_file_patterns: Vec::new(), + ignore_rules: HistoryIgnoreRules::default(), + } + } + + 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(), } } @@ -964,4 +1192,139 @@ 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$"]), + }) + .unwrap() + }; + let result = preview("docs/guide.md"); + assert_eq!(result.regex_errors.len(), 1); + assert_eq!(result.regex_errors[0].index, 0); + assert!(result + .baseline_excluded_directory_names + .contains(&"Pods".to_string())); + assert_eq!( + result.matched, + Some(HistoryIgnoreMatch::Pattern { index: 0 }) + ); + assert_eq!( + preview("a/b.TMP").matched, + Some(HistoryIgnoreMatch::Regex { index: 1 }) + ); + assert_eq!( + preview("ios/Pods/Lib/a.h").matched, + Some(HistoryIgnoreMatch::BaselineExcluded { + directory: "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(), + }) + .is_err()); + } + + #[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(), + }; + 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); + + 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/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..96a757d6d0 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, @@ -848,6 +849,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/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 11ca2e2f5c..c193cc3ec1 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -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. | @@ -3132,6 +3135,20 @@ 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. +`history.record` also accepts optional `ignoreRules: { pathPatterns, pathRegexes }`. +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. `history.ignorePreview` accepts the same +`ignoreRules` and an optional relative `path`, and returns `regexErrors` +(`{ index, message }` per rejected regex), the built-in +`baselineExcludedDirectoryNames`, and `matched`: `null` or an object tagged by +`kind` — `hidden`, `baselineExcluded` (`directory`), `pattern` or `regex` +(`index` into its list). `history.usage` and `history.clear` accept only +`storageRoot`; 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 21a92b8307..28c409a366 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9689,7 +9689,28 @@ "native.noWebBrowserCouldBeOpened": "No web browser could be opened.", "native.thisPluginAlreadyHasTooManyMessagesWaiting": "This plugin already has too many messages waiting.", "plugin.host.requestFailed": "The plugin host request could not be completed.", - "plugin.host.invalidParameters": "The plugin host request parameters are invalid." + "plugin.host.invalidParameters": "The plugin host request parameters are invalid.", + "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.noInitialCopy": "No initial copy", + "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.inNoInitialCopyEditsAreRecorded": "In {arg1}: no initial copy, edits are recorded", + "native.ignoredByWildcardLineLld": "Ignored by wildcard line {arg1}", + "native.ignoredByRegularExpressionLineLld": "Ignored by regular expression line {arg1}", + "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0": "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index f753c94407..614f88beae 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9689,7 +9689,28 @@ "native.noWebBrowserCouldBeOpened": "无法打开网页浏览器。", "native.thisPluginAlreadyHasTooManyMessagesWaiting": "此插件已有过多消息在等待。", "plugin.host.requestFailed": "未能完成插件宿主请求。", - "plugin.host.invalidParameters": "插件宿主请求的参数无效。" + "plugin.host.invalidParameters": "插件宿主请求的参数无效。", + "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.noInitialCopy": "不做初始副本", + "native.lineLldIsNotAValidRegularExpressionAndIsSkipped": "第 {arg1} 行不是有效的正则表达式,已跳过。", + "native.recorded": "会记录", + "native.hiddenInTheProjectTreeNotRecorded": "已在项目树中隐藏,不记录", + "native.inNoInitialCopyEditsAreRecorded": "位于 {arg1}:不做初始副本,编辑仍会记录", + "native.ignoredByWildcardLineLld": "被通配符第 {arg1} 行忽略", + "native.ignoredByRegularExpressionLineLld": "被正则表达式第 {arg1} 行忽略", + "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0": "本地历史会在你保存和编辑时保留文件版本,每个项目最多 100 MB。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n「不做初始副本」中的文件夹里的文件,打开项目时不会复制,编辑仍会记录。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 9243fb345c..860d1ec343 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -92647,6 +92647,257 @@ "key": "The plugin host request parameters are invalid.", "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.noInitialCopy", + "description": "Native UI: No initial copy", + "platforms": [ + "macos" + ], + "macos": { + "key": "No initial copy", + "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.inNoInitialCopyEditsAreRecorded", + "description": "Native UI: In %@: no initial copy, edits are recorded", + "platforms": [ + "macos" + ], + "macos": { + "key": "In %@: no initial copy, edits are recorded", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.ignoredByWildcardLineLld", + "description": "Native UI: Ignored by wildcard line %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ignored by wildcard line %lld", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "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.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0", + "description": "Native UI: Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/history-snapshots.json b/shared/platform-feature-matrix/features/history-snapshots.json index 58cb1ce823..23767c3c5a 100644 --- a/shared/platform-feature-matrix/features/history-snapshots.json +++ b/shared/platform-feature-matrix/features/history-snapshots.json @@ -7,7 +7,8 @@ "evidence": [ "macos/Sources/Lithe/Views/History", "macos/Sources/Lithe/Services", - "rust/lithe-core/src/project/history.rs" + "rust/lithe-core/src/project/history.rs", + "macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,6 +24,6 @@ "verificationStatus": "pending" }, "owner": "Local History", - "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。", - "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;基线跳过 node_modules、.venv、target、.claude 等生成目录和嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。" + "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、生成目录、通配符、正则)、拒绝越界路径、占用统计与清空项目历史。", + "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;基线跳过 node_modules、.venv、target、.claude 等生成目录和嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。macOS 设置 › 通用 › 本地历史可配置只作用于本地历史的忽略规则(通配符或正则,每行一条,应用后生效),项目树仍显示这些文件;测试路径实时显示被哪条规则忽略,无效正则提示行号并跳过;显示本项目占用/上限,可确认后清空本项目历史。Windows 暂无忽略规则设置,但 Core 的 history.record 已支持 ignoreRules。" } From 6ca02c61572669136449599f052b5a221e05e60b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 11:18:16 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(macos):=20=E5=86=85=E7=BD=AE=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E8=B7=AF=E5=BE=84=E4=B8=8E=E4=B8=8D=E5=81=9A=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=89=AF=E6=9C=AC=E5=8F=AF=E5=9C=A8=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=A1=86=E5=88=A0=E6=94=B9=EF=BC=8C=E6=9C=AC=E5=9C=B0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=B8=8A=E9=99=90=E5=8F=AF=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 隐藏路径、不做初始副本把内置项列进文本框,删掉一行即关闭该内置项(存为 !条目,后续版本新增的内置项仍生效) - 本地历史新增「不做初始副本」分段;Rust Core 支持 baselineExcludedDirectoryNames 与 hiddenFilePatterns 的 ! 条目,workspace.filterChanges 接受对象载荷 - 每个项目的本地历史上限可在「已用」旁调整(50–5000 MB,默认 100 MB),Rust 接受 storageLimitBytes 并限定在 10 MiB–100 GiB,基线预算为上限一半 - 用量按二进制单位显示(100 MB 而非 104.9 MB);测试路径加标签与说明 --- macos/Resources/en.lproj/Localizable.strings | 16 +- .../zh-Hans.lproj/Localizable.strings | 16 +- .../Lithe/Core/Rust/RustCoreBridge.swift | 39 +++- .../Rust/RustLocalHistoryOperations.swift | 20 +- .../AppModel/AppModel+HistoryModule.swift | 4 +- .../Bridges/FileVisibilityRules+App.swift | 4 +- .../Lithe/Models/Settings/AppSettings.swift | 25 +++ .../FileWatching/MacDirectoryWatcher.swift | 12 +- .../App/LocalHistorySettingsControl.swift | 124 ++++++++---- .../Views/App/SettingsSearchVocabulary.swift | 2 +- .../Lithe/Views/App/SettingsView.swift | 34 ++-- .../Workspace/BuiltInRuleList.swift | 38 ++++ .../Workspace/FileVisibilityRules.swift | 37 +++- .../ProjectHistoryFeatureModel.swift | 4 +- .../Ports/LocalHistoryPorts.swift | 21 ++- .../Services/LocalHistoryService.swift | 2 +- .../LocalHistoryModuleTests.swift | 4 +- .../LitheTests/BuiltInRuleListTests.swift | 22 +++ .../LocalHistoryIgnoreRulesTests.swift | 45 +++-- rust/lithe-core/src/project/files.rs | 76 +++++++- rust/lithe-core/src/project/history.rs | 177 +++++++++++++++--- rust/lithe-core/src/project/mod.rs | 4 +- rust/lithe-core/src/runtime/dispatcher.rs | 11 +- rust/lithe-core/src/tests/project.rs | 18 ++ shared/contracts/rust-core-api.md | 25 ++- shared/i18n/locales/en-US.json | 8 +- shared/i18n/locales/zh-CN.json | 8 +- shared/i18n/messages.json | 56 +++++- .../features/history-snapshots.json | 4 +- 29 files changed, 694 insertions(+), 162 deletions(-) create mode 100644 macos/Sources/LitheCoreContracts/Workspace/BuiltInRuleList.swift create mode 100644 macos/Tests/LitheTests/BuiltInRuleListTests.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 191d0567d8..fb694ad493 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1988,6 +1988,7 @@ "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"; @@ -2102,7 +2103,8 @@ "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.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded." = "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded."; +"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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again." = "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again."; +"Local history limit per project" = "Local history limit per project"; "Local history work is still active." = "Local history work is still active."; "Location" = "Location"; "Lock Worktree" = "Lock Worktree"; @@ -2420,6 +2422,7 @@ "No host yet" = "No host yet"; "No implementations found" = "No implementations found"; "No initial copy" = "No initial copy"; +"No initial copy folders" = "No initial copy folders"; "No installed plugin supports %@ yet." = "No installed plugin supports %@ yet."; "No items" = "No items"; "No language dependency sources registered" = "No language dependency sources registered"; @@ -2557,7 +2560,7 @@ "One entry per line. Directory names hide matching folders; file entries support * and ?." = "One entry per line. Directory names hide matching folders; file entries support * and ?."; "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. 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 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. 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 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"; @@ -4330,6 +4333,7 @@ "Type" = "Type"; "Type / to see commands" = "Type / to see commands"; "Type / to see options" = "Type / to see options"; +"Type a project-relative path to see whether local history records it." = "Type a project-relative path to see whether local history records it."; "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"; @@ -6732,6 +6736,7 @@ "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"; @@ -6897,7 +6902,8 @@ "native.localHistory.variant2" = "Local History…"; "native.localHistory.variant3" = "Local history"; "native.localHistoryIsAvailableForTextFiles" = "Local History is available for text files"; -"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0" = "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded."; +"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc" = "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again."; +"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"; @@ -7207,6 +7213,7 @@ "native.noHostYet" = "No host yet"; "native.noImplementationsFound" = "No implementations found"; "native.noInitialCopy" = "No initial copy"; +"native.noInitialCopyFolders" = "No initial copy folders"; "native.noInstalledPluginSupportsYet" = "No installed plugin supports %1$@ yet."; "native.noItems" = "No items"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory."; @@ -7362,7 +7369,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.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.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx3ebe586fc1" = "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 list starts with the built-in patterns. Delete one to show those files again; type it back to hide them."; "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."; "native.oneSentenceFocusedOnTheMainChange" = "One sentence focused on the main change"; @@ -9036,6 +9043,7 @@ "native.turningThisOffStopsEveryAgentProcess" = "Turning this off stops every Agent process."; "native.turns" = "Turns"; "native.type" = "Type"; +"native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt" = "Type a project-relative path to see whether local history records it."; "native.typeChanged" = "Type changed"; "native.typeToSeeCommands" = "Type / to see commands"; "native.typeToSeeOptions" = "Type / to see options"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index a93c3b628f..f3f9bab920 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1988,6 +1988,7 @@ "License" = "许可证"; "Lifecycle" = "生命周期"; "Light" = "浅色"; +"Limit per project" = "每个项目上限"; "Line" = "行"; "Line %lld is not a valid regular expression and is skipped." = "第 %lld 行不是有效的正则表达式,已跳过。"; "Line Breakpoints" = "行断点"; @@ -2102,7 +2103,8 @@ "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.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded." = "本地历史会在你保存和编辑时保留文件版本,每个项目最多 100 MB。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n「不做初始副本」中的文件夹里的文件,打开项目时不会复制,编辑仍会记录。"; +"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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again." = "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n不做初始副本:每行一个文件夹名。其中的文件在打开项目时不会复制,编辑仍会记录。列表开头是内置文件夹,删掉一行即可重新复制其中的文件。"; +"Local history limit per project" = "每个项目的本地历史上限"; "Local history work is still active." = "本地历史任务仍在进行。"; "Location" = "位置"; "Lock Worktree" = "锁定工作树"; @@ -2420,6 +2422,7 @@ "No host yet" = "尚未填写主机"; "No implementations found" = "没有找到实现"; "No initial copy" = "不做初始副本"; +"No initial copy folders" = "不做初始副本的文件夹"; "No installed plugin supports %@ yet." = "尚未安装支持 %@ 的插件。"; "No items" = "暂无项目"; "No language dependency sources registered" = "未注册语言依赖来源"; @@ -2557,7 +2560,7 @@ "One entry per line. Directory names hide matching folders; file entries support * and ?." = "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。"; "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. 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 list starts with the built-in patterns. Delete one to show those files again; type it back to hide them." = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\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" = "聚焦主要更改的一句话"; @@ -4330,6 +4333,7 @@ "Type" = "类型"; "Type / to see commands" = "输入 / 查看命令"; "Type / to see options" = "输入 / 查看选项"; +"Type a project-relative path to see whether local history records it." = "输入项目内的相对路径,查看本地历史是否会记录它。"; "Type changed" = "类型已更改"; "Type your task or / for commands" = "输入任务,或用 / 选择命令"; "Type your task, / for commands or @ for files" = "输入任务,用 / 选择命令,用 @ 添加文件"; @@ -6732,6 +6736,7 @@ "native.license" = "许可证"; "native.lifecycle" = "生命周期"; "native.light" = "浅色"; +"native.limitPerProject" = "每个项目上限"; "native.line" = "行"; "native.lineBreakpointActions" = "行断点操作"; "native.lineBreakpoints" = "行断点"; @@ -6897,7 +6902,8 @@ "native.localHistory.variant2" = "本地历史…"; "native.localHistory.variant3" = "本地历史"; "native.localHistoryIsAvailableForTextFiles" = "本地历史仅适用于文本文件"; -"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0" = "本地历史会在你保存和编辑时保留文件版本,每个项目最多 100 MB。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n「不做初始副本」中的文件夹里的文件,打开项目时不会复制,编辑仍会记录。"; +"native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc" = "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n不做初始副本:每行一个文件夹名。其中的文件在打开项目时不会复制,编辑仍会记录。列表开头是内置文件夹,删掉一行即可重新复制其中的文件。"; +"native.localHistoryLimitPerProject" = "每个项目的本地历史上限"; "native.localHistoryWorkIsStillActive" = "本地历史任务仍在进行。"; "native.localJDKHomeOptional" = "本地 JDK 主目录(可选)"; "native.localPackage" = "本地插件包"; @@ -7207,6 +7213,7 @@ "native.noHostYet" = "尚未填写主机"; "native.noImplementationsFound" = "没有找到实现"; "native.noInitialCopy" = "不做初始副本"; +"native.noInitialCopyFolders" = "不做初始副本的文件夹"; "native.noInstalledPluginSupportsYet" = "尚未安装支持 %1$@ 的插件。"; "native.noItems" = "暂无项目"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。"; @@ -7362,7 +7369,7 @@ "native.oneFile" = "单个文件:%1$@"; "native.oneNAMEValueEntryPerLine" = "每行填写一个 NAME=value"; "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace" = "每行一个路径;相对路径以当前工作区为基准。"; -"native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a" = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/*` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n下方的内置规则始终生效。"; +"native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx3ebe586fc1" = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\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 搜索窗口。"; "native.oneSentenceFocusedOnTheMainChange" = "聚焦主要更改的一句话"; @@ -9036,6 +9043,7 @@ "native.turningThisOffStopsEveryAgentProcess" = "关闭后会停止所有 Agent 进程。"; "native.turns" = "轮次"; "native.type" = "类型"; +"native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt" = "输入项目内的相对路径,查看本地历史是否会记录它。"; "native.typeChanged" = "类型已更改"; "native.typeToSeeCommands" = "输入 / 查看命令"; "native.typeToSeeOptions" = "输入 / 查看选项"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index c41b1d9f77..2b59d164e2 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -295,7 +295,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { } let regexErrors: [RegexError] - let baselineExcludedDirectoryNames: [String] + let builtInBaselineExcludedDirectoryNames: [String] let matched: Match? } @@ -1645,20 +1645,24 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let hiddenDirectoryNames: [String] let hiddenFilePatterns: [String] let ignoreRules: HistoryIgnoreRules + let storageLimitBytes: Int64? } private struct HistoryIgnoreRules: Encodable { let pathPatterns: [String] let pathRegexes: [String] + let baselineExcludedDirectoryNames: [String] } private struct HistoryIgnorePreviewRequest: Encodable { let path: String? let ignoreRules: HistoryIgnoreRules + let hiddenFilePatterns: [String] } private struct HistoryStorageRequest: Encodable { let storageRoot: String + var storageLimitBytes: Int64? } private struct HistoryEntriesRequest: Encodable { @@ -2736,7 +2740,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { hiddenDirectoryNames: [String], hiddenFilePatterns: [String], ignorePatterns: [String] = [], - ignoreRegexes: [String] = [] + ignoreRegexes: [String] = [], + baselineExcludedDirectoryNames: [String] = [], + storageLimitBytes: Int64? = nil ) -> HistoryEntryPayload? { execute( command: "history.record", @@ -2749,23 +2755,42 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { pruneExpired: pruneExpired, hiddenDirectoryNames: hiddenDirectoryNames, hiddenFilePatterns: hiddenFilePatterns, - ignoreRules: HistoryIgnoreRules(pathPatterns: ignorePatterns, pathRegexes: ignoreRegexes) + ignoreRules: HistoryIgnoreRules( + pathPatterns: ignorePatterns, + pathRegexes: ignoreRegexes, + baselineExcludedDirectoryNames: baselineExcludedDirectoryNames + ), + storageLimitBytes: storageLimitBytes ) ) } - func historyIgnorePreview(path: String?, ignorePatterns: [String], ignoreRegexes: [String]) -> HistoryIgnorePreviewPayload? { + func historyIgnorePreview( + path: String?, + ignorePatterns: [String], + ignoreRegexes: [String], + baselineExcludedDirectoryNames: [String], + hiddenFilePatterns: [String] + ) -> HistoryIgnorePreviewPayload? { execute( command: "history.ignorePreview", payload: HistoryIgnorePreviewRequest( path: path, - ignoreRules: HistoryIgnoreRules(pathPatterns: ignorePatterns, pathRegexes: ignoreRegexes) + ignoreRules: HistoryIgnoreRules( + pathPatterns: ignorePatterns, + pathRegexes: ignoreRegexes, + baselineExcludedDirectoryNames: baselineExcludedDirectoryNames + ), + hiddenFilePatterns: hiddenFilePatterns ) ) } - func historyUsage(storageURL: URL) -> HistoryUsagePayload? { - execute(command: "history.usage", payload: HistoryStorageRequest(storageRoot: storageURL.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 { diff --git a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift index f0f4acf24a..402a171c0a 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift @@ -11,7 +11,9 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, hiddenFilePatterns: visibilityRules.hiddenFilePatterns, ignorePatterns: visibilityRules.ignorePatterns, - ignoreRegexes: visibilityRules.ignoreRegexes + ignoreRegexes: visibilityRules.ignoreRegexes, + baselineExcludedDirectoryNames: visibilityRules.baselineExcludedDirectoryNames, + storageLimitBytes: visibilityRules.storageLimitBytes ).map(Self.makePayload) } @@ -26,14 +28,20 @@ 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) -> LocalHistoryUsage? { - core.historyUsage(storageURL: storageURL).map { LocalHistoryUsage(usedBytes: $0.usedBytes, limitBytes: $0.limitBytes) } + 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?, ignorePatterns: [String], ignoreRegexes: [String]) -> LocalHistoryIgnorePreview? { - guard let value = core.historyIgnorePreview(path: path, ignorePatterns: ignorePatterns, ignoreRegexes: ignoreRegexes) else { return nil } + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules) -> LocalHistoryIgnorePreview? { + guard let value = core.historyIgnorePreview( + path: path, + ignorePatterns: rules.ignorePatterns, + ignoreRegexes: rules.ignoreRegexes, + baselineExcludedDirectoryNames: rules.baselineExcludedDirectoryNames, + hiddenFilePatterns: rules.hiddenFilePatterns + ) else { return nil } let matched: LocalHistoryIgnoreMatch? = switch value.matched { case nil: nil case .hidden: .hidden @@ -43,7 +51,7 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { } return LocalHistoryIgnorePreview( regexErrors: Dictionary(value.regexErrors.map { ($0.index, $0.message) }, uniquingKeysWith: { first, _ in first }), - baselineExcludedDirectoryNames: value.baselineExcludedDirectoryNames, + builtInBaselineExcludedDirectoryNames: value.builtInBaselineExcludedDirectoryNames, matched: matched ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift index 85dd476d7c..88624584c8 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift @@ -12,8 +12,8 @@ extension AppModel { await historyModuleCoordinator.activate() } - func localHistoryIgnorePreview(path: String?, patterns: [String], regexes: [String]) async -> LocalHistoryIgnorePreview? { - await activateHistoryModule()?.previewIgnoreRules(path: path, patterns: patterns, regexes: regexes) + func localHistoryIgnorePreview(path: String?, rules: LocalHistoryVisibilityRules) async -> LocalHistoryIgnorePreview? { + await activateHistoryModule()?.previewIgnoreRules(path: path, rules: rules) } /// Nil when no project is open. diff --git a/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift b/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift index 2e1f4b6452..5294f717b6 100644 --- a/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift +++ b/macos/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift @@ -35,7 +35,9 @@ extension AppSettings { hiddenDirectoryNames: visibility.hiddenDirectoryNames, hiddenFilePatterns: visibility.hiddenFilePatterns, ignorePatterns: localHistoryIgnorePatterns, - ignoreRegexes: localHistoryIgnoreRegexes + ignoreRegexes: localHistoryIgnoreRegexes, + baselineExcludedDirectoryNames: localHistoryBaselineExcludedDirectoryNames, + storageLimitBytes: Int64(localHistoryStorageLimitMegabytes) * 1024 * 1024 ) } } diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index e82897c0a6..b42e574c6e 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -51,6 +51,8 @@ final class AppSettings: ObservableObject { static let searchExclusionsEnabled = "settings.searchExclusionsEnabled" static let localHistoryIgnorePatterns = "settings.localHistoryIgnorePatterns" static let localHistoryIgnoreRegexes = "settings.localHistoryIgnoreRegexes" + static let localHistoryBaselineExcludedDirectoryNames = "settings.localHistoryBaselineExcludedDirectoryNames" + static let localHistoryStorageLimitMegabytes = "settings.localHistoryStorageLimitMegabytes" static let gitExecutable = "settings.gitExecutable" static let gitUseCredentialHelper = "settings.gitUseCredentialHelper" static let gitFetchOptions = "settings.gitFetchOptions" @@ -209,6 +211,23 @@ final class AppSettings: ObservableObject { if oldValue != localHistoryIgnoreRegexes { localHistoryIgnoreRulesDidChange.send() } } } + /// User changes to the No initial copy folders: added names, and `!name` + /// for removed built-ins, so later built-ins still apply. + @Published var localHistoryBaselineExcludedDirectoryNames: [String] { + didSet { + defaults.set(localHistoryBaselineExcludedDirectoryNames, forKey: Key.localHistoryBaselineExcludedDirectoryNames) + if oldValue != localHistoryBaselineExcludedDirectoryNames { localHistoryIgnoreRulesDidChange.send() } + } + } + static let defaultLocalHistoryStorageLimitMegabytes = 100 + static let localHistoryStorageLimitRange = 50...5000 + /// 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() } @@ -474,6 +493,10 @@ final class AppSettings: ObservableObject { searchExclusionsEnabled = defaults.object(forKey: Key.searchExclusionsEnabled) as? Bool ?? true localHistoryIgnorePatterns = defaults.stringArray(forKey: Key.localHistoryIgnorePatterns) ?? [] localHistoryIgnoreRegexes = defaults.stringArray(forKey: Key.localHistoryIgnoreRegexes) ?? [] + localHistoryBaselineExcludedDirectoryNames = defaults.stringArray(forKey: Key.localHistoryBaselineExcludedDirectoryNames) ?? [] + 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) @@ -685,6 +708,8 @@ final class AppSettings: ObservableObject { searchExclusionsEnabled = true localHistoryIgnorePatterns = [] localHistoryIgnoreRegexes = [] + localHistoryBaselineExcludedDirectoryNames = [] + 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 index ddde9c6d46..883387a50e 100644 --- a/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift +++ b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift @@ -1,11 +1,13 @@ +import LitheCoreContracts import LitheLocalHistoryModule import LitheModuleAPI import SwiftUI -/// 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. +/// Local history storage, ignore rules and No initial copy folders. 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 } + private enum RuleKind: Hashable { case wildcard, regex, baseline } @EnvironmentObject private var settingsContext: SettingsModelContext private var model: AppModel { settingsContext.model } @@ -13,6 +15,9 @@ struct LocalHistorySettingsControl: View { @State private var kind = RuleKind.wildcard @State private var patternsDraft = "" @State private var regexesDraft = "" + @State private var baselineDraft = "" + /// Rust Core owns the built-in folder list; it arrives with the first preview. + @State private var baselineBuiltIns: [String]? @State private var testPath = "" @State private var preview: LocalHistoryIgnorePreview? @State private var usage: LocalHistoryUsage? @@ -22,20 +27,26 @@ struct LocalHistorySettingsControl: View { var body: some View { VStack(alignment: .leading, spacing: 8) { storageRow - LitheSettingsSegmentedControl(selection: $kind, options: [.wildcard, .regex], width: 220) { - $0 == .wildcard ? "Wildcard" : "Regular expression" + LitheSettingsSegmentedControl(selection: $kind, options: [.wildcard, .regex, .baseline], width: 360) { + switch $0 { + case .wildcard: "Wildcard" + case .regex: "Regular expression" + case .baseline: "No initial copy" + } } - LitheTextEditor(text: kind == .wildcard ? $patternsDraft : $regexesDraft, height: 72) - .accessibilityLabel(kind == .wildcard ? Text("Wildcard ignore rules") : Text("Regular expression ignore rules")) + editor regexErrors HStack(spacing: 6) { + Text("Test path") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + .help(Text("Type a project-relative path to see whether local history records it.")) LitheTextField(verbatim: "src/generated/Api.java", text: $testPath) .frame(maxWidth: 260) .accessibilityLabel(Text("Test path")) testResult } HStack(spacing: 6) { - baselinePills Spacer(minLength: 8) Button("Apply") { apply() } .buttonStyle(LitheActionButtonStyle()) @@ -45,6 +56,7 @@ struct LocalHistorySettingsControl: View { .onAppear(perform: syncDrafts) .onChange(of: settings.localHistoryIgnorePatterns) { _ in syncDrafts() } .onChange(of: settings.localHistoryIgnoreRegexes) { _ in syncDrafts() } + .onChange(of: settings.localHistoryBaselineExcludedDirectoryNames) { _ in syncDrafts() } .task { usage = await model.localHistoryUsage() } .task(id: previewKey) { // Debounce typing; a newer draft cancels this task. @@ -52,10 +64,16 @@ struct LocalHistorySettingsControl: View { guard !Task.isCancelled else { return } let result = await model.localHistoryIgnorePreview( path: testPath.trimmingCharacters(in: .whitespaces), - patterns: lines(patternsDraft), - regexes: lines(regexesDraft) + rules: draftRules ) - if !Task.isCancelled { preview = result } + guard !Task.isCancelled else { return } + preview = result + if baselineBuiltIns == nil, let builtIns = result?.builtInBaselineExcludedDirectoryNames { + baselineBuiltIns = builtIns + baselineDraft = BuiltInRuleList.displayed( + stored: settings.localHistoryBaselineExcludedDirectoryNames, builtIns: builtIns + ).joined(separator: "\n") + } } .alert("Clear this project's local history?", isPresented: $isConfirmingClear) { Button("Clear", role: .destructive) { clear() } @@ -74,8 +92,11 @@ struct LocalHistorySettingsControl: View { Text("Used") .font(LitheTheme.uiFont(size: 11)) .foregroundStyle(LitheTheme.tertiaryText) - LitheSettingsPill(verbatim: "\(Self.bytes(usage.usedBytes)) / \(Self.bytes(usage.limitBytes))", kind: .value) - .help(Text("When the limit is reached, the oldest versions are removed first.")) + 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: { isClearing ? Text("Clearing…") : Text("Clear Project History…") @@ -83,6 +104,11 @@ struct LocalHistorySettingsControl: View { .buttonStyle(LitheActionButtonStyle()) .disabled(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) @@ -90,6 +116,19 @@ struct LocalHistorySettingsControl: View { } } + /// 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" } + ) + .help(Text("When the limit is reached, the oldest versions are removed first.")) + } + private func clear() { isClearing = true Task { @@ -101,6 +140,21 @@ struct LocalHistorySettingsControl: View { // MARK: Rules + @ViewBuilder + private var editor: some View { + switch kind { + case .wildcard: + LitheTextEditor(text: $patternsDraft, height: 72) + .accessibilityLabel(Text("Wildcard ignore rules")) + case .regex: + LitheTextEditor(text: $regexesDraft, height: 72) + .accessibilityLabel(Text("Regular expression ignore rules")) + case .baseline: + LitheTextEditor(text: $baselineDraft, height: 120) + .accessibilityLabel(Text("No initial copy folders")) + } + } + @ViewBuilder private var regexErrors: some View { if kind == .regex, let errors = preview?.regexErrors, !errors.isEmpty { @@ -147,38 +201,43 @@ struct LocalHistorySettingsControl: View { return false } - /// Generated folders get no initial copy when a project opens; edits inside them are still recorded. - @ViewBuilder - private var baselinePills: some View { - let names = preview?.baselineExcludedDirectoryNames ?? [] - if !names.isEmpty { - let shown = names.prefix(3) - HStack(spacing: 6) { - Text("No initial copy") - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.tertiaryText) - ForEach(Array(shown), id: \.self) { LitheSettingsPill(verbatim: $0, kind: .value) } - if names.count > shown.count { - LitheSettingsPill(verbatim: "+\(names.count - shown.count)") - } - } - .help(Text(verbatim: names.joined(separator: " "))) - } + private var previewKey: String { [testPath, patternsDraft, regexesDraft, baselineDraft].joined(separator: "\u{0}") } + + /// 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: lines(patternsDraft), + ignoreRegexes: lines(regexesDraft), + baselineExcludedDirectoryNames: storedBaseline + ) } - private var previewKey: String { [testPath, patternsDraft, regexesDraft].joined(separator: "\u{0}") } + /// Saved form of the folder draft; the saved value until the built-ins are known. + private var storedBaseline: [String] { + guard let baselineBuiltIns else { return settings.localHistoryBaselineExcludedDirectoryNames } + return BuiltInRuleList.stored(lines: lines(baselineDraft), builtIns: baselineBuiltIns) + } private var hasChanges: Bool { entries(patternsDraft) != settings.localHistoryIgnorePatterns || entries(regexesDraft) != settings.localHistoryIgnoreRegexes + || storedBaseline != settings.localHistoryBaselineExcludedDirectoryNames } private func syncDrafts() { patternsDraft = settings.localHistoryIgnorePatterns.joined(separator: "\n") regexesDraft = settings.localHistoryIgnoreRegexes.joined(separator: "\n") + if let baselineBuiltIns { + baselineDraft = BuiltInRuleList.displayed( + stored: settings.localHistoryBaselineExcludedDirectoryNames, builtIns: baselineBuiltIns + ).joined(separator: "\n") + } } private func apply() { + settings.localHistoryBaselineExcludedDirectoryNames = storedBaseline settings.localHistoryIgnorePatterns = entries(patternsDraft) settings.localHistoryIgnoreRegexes = entries(regexesDraft) // Echo what was stored, without the blank lines. @@ -197,6 +256,7 @@ struct LocalHistorySettingsControl: View { } private static func bytes(_ count: Int64) -> String { - ByteCountFormatter.string(fromByteCount: count, countStyle: .file) + // 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 02129bb81f..e8e1b67a02 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", "Local history", "Wildcard", "Regular expression", "Clear Project History…"] + ["General", "Appearance", "Color theme", "Appearance mode", "Language", "Projects", "Files", "Version control", "Hidden paths", "Local history", "Wildcard", "Regular expression", "No initial copy", "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 d45d97c915..c5a5b328c1 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -549,35 +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. 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 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.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded.") { + 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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again.") { 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 +1743,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/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..dc0e01954f 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift @@ -31,13 +31,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 +95,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 +109,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) } diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index c3631c320c..f5a0fe7c34 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -100,10 +100,10 @@ public final class ProjectHistoryFeatureModel: ObservableObject { } /// Validates ignore rules for the settings page; needs no open project. - public func previewIgnoreRules(path: String?, patterns: [String], regexes: [String]) async -> LocalHistoryIgnorePreview? { + public func previewIgnoreRules(path: String?, rules: LocalHistoryVisibilityRules) async -> LocalHistoryIgnorePreview? { let operations = localHistoryOperations return await Task.detached { - operations.ignorePreview(path: path, ignorePatterns: patterns, ignoreRegexes: regexes) + operations.ignorePreview(path: path, rules: rules) }.value } diff --git a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift index 98de1b38eb..d277c087a2 100644 --- a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift +++ b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift @@ -6,12 +6,20 @@ public struct LocalHistoryVisibilityRules: Hashable, Sendable { /// Files the user keeps out of local history only; the project tree still shows them. public let ignorePatterns: [String] public let ignoreRegexes: [String] + /// Folders added to the built-in No initial copy list, and `!name` entries + /// for built-in ones the user removed. + public let baselineExcludedDirectoryNames: [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] = []) { + ignorePatterns: [String] = [], ignoreRegexes: [String] = [], + baselineExcludedDirectoryNames: [String] = [], storageLimitBytes: Int64? = nil) { self.hiddenDirectoryNames = hiddenDirectoryNames self.hiddenFilePatterns = hiddenFilePatterns self.ignorePatterns = ignorePatterns self.ignoreRegexes = ignoreRegexes + self.baselineExcludedDirectoryNames = baselineExcludedDirectoryNames + self.storageLimitBytes = storageLimitBytes } } @@ -38,11 +46,12 @@ public enum LocalHistoryIgnoreMatch: Equatable, Sendable { /// 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] - public let baselineExcludedDirectoryNames: [String] + /// The built-in No initial copy folders, before the user's changes. + public let builtInBaselineExcludedDirectoryNames: [String] public let matched: LocalHistoryIgnoreMatch? - public init(regexErrors: [Int: String], baselineExcludedDirectoryNames: [String], matched: LocalHistoryIgnoreMatch?) { + public init(regexErrors: [Int: String], builtInBaselineExcludedDirectoryNames: [String], matched: LocalHistoryIgnoreMatch?) { self.regexErrors = regexErrors - self.baselineExcludedDirectoryNames = baselineExcludedDirectoryNames + self.builtInBaselineExcludedDirectoryNames = builtInBaselineExcludedDirectoryNames self.matched = matched } } @@ -70,9 +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) -> LocalHistoryUsage? + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? func clear(at storageURL: URL) -> Bool - func ignorePreview(path: String?, ignorePatterns: [String], ignoreRegexes: [String]) -> LocalHistoryIgnorePreview? + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules) -> LocalHistoryIgnorePreview? } public protocol LocalHistoryWorkspaceAccess: Sendable { diff --git a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift index 765c61a505..2bffff6946 100644 --- a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift +++ b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift @@ -27,7 +27,7 @@ actor LocalHistoryService { } func usage() -> LocalHistoryUsage? { - operations.usage(at: storageURL) + operations.usage(at: storageURL, limitBytes: visibilityRules.storageLimitBytes) } func clear() -> Bool { diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index 4ff282b966..f60e6592d4 100644 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -72,7 +72,7 @@ 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) -> LocalHistoryUsage? { nil } + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } func clear(at storageURL: URL) -> Bool { false } - func ignorePreview(path: String?, ignorePatterns: [String], ignoreRegexes: [String]) -> LocalHistoryIgnorePreview? { nil } + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules) -> 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/LocalHistoryIgnoreRulesTests.swift b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift index b3a281dc7e..79cd034ff2 100644 --- a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift +++ b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift @@ -7,21 +7,35 @@ import Testing struct LocalHistoryIgnoreRulesTests { private let operations = RustLocalHistoryOperations(core: RustCoreBridge()) + private func preview(_ path: String, patterns: [String] = [], regexes: [String] = [], + hidden: [String] = [], baseline: [String] = []) -> LocalHistoryIgnorePreview? { + operations.ignorePreview(path: path, rules: LocalHistoryVisibilityRules( + hiddenDirectoryNames: [], hiddenFilePatterns: hidden, + ignorePatterns: patterns, ignoreRegexes: regexes, baselineExcludedDirectoryNames: baseline + )) + } + @Test func previewReportsInvalidRegexLinesAndTheMatchingRule() throws { - let preview = try #require(operations.ignorePreview( - path: "src/Generated/Api.java", - ignorePatterns: ["*.log", ""], - ignoreRegexes: ["", "(unclosed", "generated/"] - )) - #expect(Array(preview.regexErrors.keys) == [1]) - #expect(preview.matched == .regex(index: 2)) - #expect(preview.baselineExcludedDirectoryNames.contains("Pods")) - - #expect(operations.ignorePreview(path: "logs/run.log", ignorePatterns: ["*.log"], ignoreRegexes: [])?.matched == .pattern(index: 0)) - #expect(operations.ignorePreview(path: "ios/Pods/A/a.h", ignorePatterns: [], ignoreRegexes: [])?.matched == .baselineExcluded(directory: "Pods")) - #expect(operations.ignorePreview(path: ".git/config", ignorePatterns: [], ignoreRegexes: [])?.matched == .hidden) - #expect(operations.ignorePreview(path: "src/Main.java", ignorePatterns: [], ignoreRegexes: [])?.matched == nil) + 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.builtInBaselineExcludedDirectoryNames.contains("Pods")) + + #expect(preview("logs/run.log", patterns: ["*.log"])?.matched == .pattern(index: 0)) + #expect(preview("ios/Pods/A/a.h")?.matched == .baselineExcluded(directory: "Pods")) + #expect(preview(".git/config")?.matched == .hidden) + #expect(preview("src/Main.java")?.matched == nil) + } + + @Test + func builtInsCanBeTurnedOffAndFoldersAdded() { + #expect(preview("ios/Pods/A/a.h", baseline: ["!Pods"])?.matched == nil) + #expect(preview("gen/A.java", baseline: ["gen"])?.matched == .baselineExcluded(directory: "gen")) + // Showing a hidden built-in records it, but its folder still gets no initial copy. + #expect(preview(".git/config", hidden: ["!**/.git/"])?.matched == .baselineExcluded(directory: ".git")) + #expect(preview(".git/config", hidden: ["!**/.git/"], baseline: ["!.git"])?.matched == nil) } @Test @@ -42,10 +56,11 @@ struct LocalHistoryIgnoreRulesTests { #expect(record("run.log") == nil) #expect(record("build/out.txt") == nil) #expect(record("src/App.swift") != nil) - #expect(try #require(operations.usage(at: storage)).usedBytes > 0) + #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) == LocalHistoryUsage(usedBytes: 0, limitBytes: 100 * 1024 * 1024)) + #expect(operations.usage(at: storage, limitBytes: nil) == LocalHistoryUsage(usedBytes: 0, limitBytes: 100 * 1024 * 1024)) #expect(FileManager.default.fileExists(atPath: storage.path)) } } 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 b6798b9070..e532967a67 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -14,11 +14,12 @@ 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; @@ -80,6 +81,9 @@ pub struct HistoryRecordRequest { 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, } /// User rules for files whose changes local history should not record. @@ -95,6 +99,39 @@ pub struct HistoryIgnoreRules { pub path_patterns: Vec, #[serde(default)] pub path_regexes: Vec, + /// Folders added to the built-in baseline exclusions, and `!name` entries + /// that turn a built-in one off. + #[serde(default)] + pub baseline_excluded_directory_names: Vec, +} + +impl HistoryIgnoreRules { + /// Built-in baseline exclusions, minus those turned off, plus the user's own. + fn baseline_excluded_directories(&self) -> Vec { + let entries = self + .baseline_excluded_directory_names + .iter() + .map(|name| name.trim()) + .filter(|name| !name.is_empty()); + let disabled = entries + .clone() + .filter_map(|name| name.strip_prefix('!')) + .map(str::trim) + .collect::>(); + let mut names: Vec = Vec::new(); + for name in BASELINE_EXCLUDED_DIRECTORY_NAMES + .iter() + .copied() + .chain(entries.filter(|name| !name.starts_with('!'))) + { + if !disabled.iter().any(|off| off.eq_ignore_ascii_case(name)) + && !names.iter().any(|kept| kept.eq_ignore_ascii_case(name)) + { + names.push(name.to_string()); + } + } + names + } } #[derive(Debug, Deserialize)] @@ -105,6 +142,9 @@ pub struct HistoryIgnorePreviewRequest { 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, } #[derive(Debug, Serialize, PartialEq, Eq)] @@ -138,7 +178,8 @@ pub enum HistoryIgnoreMatch { /// Validation and sample-path result for the local history settings. pub struct HistoryIgnorePreviewResponse { pub regex_errors: Vec, - pub baseline_excluded_directory_names: Vec, + /// The built-in defaults, for settings to show and edit against. + pub built_in_baseline_excluded_directory_names: Vec, pub matched: Option, } @@ -147,6 +188,9 @@ pub struct HistoryIgnorePreviewResponse { /// 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)] @@ -238,7 +282,8 @@ 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)); @@ -319,8 +365,8 @@ pub fn record(request: HistoryRecordRequest) -> Result { let relative = safe_relative_path(path)?; - preview_match(&request.ignore_rules, &relative) + preview_match( + &request.ignore_rules, + &request.hidden_file_patterns, + &relative, + ) } _ => None, }; Ok(HistoryIgnorePreviewResponse { regex_errors, - baseline_excluded_directory_names: BASELINE_EXCLUDED_DIRECTORY_NAMES + built_in_baseline_excluded_directory_names: BASELINE_EXCLUDED_DIRECTORY_NAMES .iter() .map(|name| (*name).to_string()) .collect(), @@ -502,10 +552,23 @@ pub fn usage(request: HistoryStorageRequest) -> Result) -> 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)?; @@ -518,8 +581,14 @@ pub fn clear(request: HistoryStorageRequest) -> Result<(), CoreError> { result.map_err(CoreError::from) } -fn preview_match(rules: &HistoryIgnoreRules, relative_path: &str) -> Option { - if VisibilityRules::new(Vec::new(), Vec::new()).is_hidden(relative_path, false) { +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); } if let Some(found) = ignored_rule(rules, relative_path) { @@ -527,12 +596,13 @@ fn preview_match(rules: &HistoryIgnoreRules, relative_path: &str) -> Option>(); components.pop(); + let excluded = rules.baseline_excluded_directories(); components.into_iter().find_map(|component| { - BASELINE_EXCLUDED_DIRECTORY_NAMES + excluded .iter() .find(|name| component.eq_ignore_ascii_case(name)) .map(|name| HistoryIgnoreMatch::BaselineExcluded { - directory: (*name).to_string(), + directory: name.clone(), }) }) } @@ -703,12 +773,13 @@ fn trim_entries(directory: &Path, storage: &Path) { /// 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 excluded = rules.baseline_excluded_directories(); let mut removed = false; for entry in all_entries(storage) { let excluded_baseline = entry.reason == "projectBaseline" - && has_excluded_baseline_component(&entry.relative_path); + && has_excluded_baseline_component(&entry.relative_path, &excluded); if entry.timestamp < cutoff || excluded_baseline { remove_entry(storage, &entry); removed = true; @@ -844,11 +915,11 @@ fn invalidate_storage_usage(storage: &Path) { } } -fn has_excluded_baseline_component(relative_path: &str) -> bool { +fn has_excluded_baseline_component(relative_path: &str, excluded: &[String]) -> bool { let mut components = relative_path.split('/').collect::>(); components.pop(); components.iter().any(|component| { - BASELINE_EXCLUDED_DIRECTORY_NAMES + excluded .iter() .any(|name| component.eq_ignore_ascii_case(name)) }) @@ -858,8 +929,12 @@ fn has_excluded_baseline_component(relative_path: &str) -> bool { /// /// 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) { +fn is_excluded_from_baseline( + workspace: &Path, + relative_path: &str, + rules: &HistoryIgnoreRules, +) -> bool { + if has_excluded_baseline_component(relative_path, &rules.baseline_excluded_directories()) { return true; } let mut directory = workspace.to_path_buf(); @@ -997,13 +1072,24 @@ mod tests { 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(), + baseline_excluded_directory_names: Vec::new(), } } @@ -1169,7 +1255,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, @@ -1249,6 +1335,7 @@ mod tests { ignore_preview(HistoryIgnorePreviewRequest { path: Some(path.into()), ignore_rules: ignore_rules(&["docs/*.md"], &["(", r"\.tmp$"]), + hidden_file_patterns: Vec::new(), }) .unwrap() }; @@ -1256,7 +1343,7 @@ mod tests { assert_eq!(result.regex_errors.len(), 1); assert_eq!(result.regex_errors[0].index, 0); assert!(result - .baseline_excluded_directory_names + .built_in_baseline_excluded_directory_names .contains(&"Pods".to_string())); assert_eq!( result.matched, @@ -1280,10 +1367,45 @@ mod tests { assert!(ignore_preview(HistoryIgnorePreviewRequest { path: Some("../outside".into()), ignore_rules: HistoryIgnoreRules::default(), + hidden_file_patterns: Vec::new(), }) .is_err()); } + #[test] + fn baseline_exclusions_can_be_extended_and_turned_off() { + let rules = HistoryIgnoreRules { + baseline_excluded_directory_names: vec!["generated".into(), " !Pods ".into()], + ..HistoryIgnoreRules::default() + }; + let excluded = rules.baseline_excluded_directories(); + assert!(excluded.contains(&"generated".to_string())); + assert!(!excluded + .iter() + .any(|name| name.eq_ignore_ascii_case("pods"))); + assert!(excluded.contains(&".idea".to_string())); + assert!(has_excluded_baseline_component( + "src/generated/A.java", + &excluded + )); + assert!(!has_excluded_baseline_component( + "ios/Pods/Lib/a.h", + &excluded + )); + assert_eq!(preview_match(&rules, &[], "ios/Pods/Lib/a.h"), None); + // A turned-off hidden folder is visible again; `build` still gets no baseline. + assert_eq!( + preview_match(&rules, &["!**/build/".into()], "build/out.txt"), + Some(HistoryIgnoreMatch::BaselineExcluded { + directory: "build".into() + }) + ); + assert_eq!( + preview_match(&rules, &[], "build/out.txt"), + Some(HistoryIgnoreMatch::Hidden) + ); + } + #[test] fn usage_counts_allocated_blocks_and_clear_removes_every_snapshot() { let workspace = temporary_root("history-usage-workspace"); @@ -1291,6 +1413,7 @@ mod tests { 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( @@ -1313,6 +1436,12 @@ mod tests { // 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); 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/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 96a757d6d0..2c1cf77947 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -556,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, 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 c193cc3ec1..58d06bedff 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 | @@ -3135,17 +3135,26 @@ 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. -`history.record` also accepts optional `ignoreRules: { pathPatterns, pathRegexes }`. +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, +baselineExcludedDirectoryNames }` 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. `history.ignorePreview` accepts the same -`ignoreRules` and an optional relative `path`, and returns `regexErrors` -(`{ index, message }` per rejected regex), the built-in -`baselineExcludedDirectoryNames`, and `matched`: `null` or an object tagged by +or empty regex never matches. `baselineExcludedDirectoryNames` adds folder +names whose files get no initial copy when a project opens, on top of the +built-in list; `!name` turns a built-in off. `history.ignorePreview` accepts the +same `ignoreRules`, optional `hiddenFilePatterns` and an optional relative +`path`, and returns `regexErrors` (`{ index, message }` per rejected regex), the +built-in `builtInBaselineExcludedDirectoryNames`, and `matched`: `null` or an object tagged by `kind` — `hidden`, `baselineExcluded` (`directory`), `pattern` or `regex` -(`index` into its list). `history.usage` and `history.clear` accept only -`storageRoot`; usage rescans the disk and returns `{ usedBytes, limitBytes }` in +(`index` into its list). `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 }`. diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 28c409a366..fa048bbb4f 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -5326,7 +5326,7 @@ "native.oneFile": "One file: {arg1}", "native.oneNAMEValueEntryPerLine": "One NAME=value entry per line", "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace": "One path per line. Relative paths are resolved from the workspace.", - "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.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx3ebe586fc1": "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 list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.", "native.onePatternPerLineLogCacheOnlyAffectsThisSearchEverywhereWindow": "One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window.", "native.oneSentenceFocusedOnTheMainChange": "One sentence focused on the main change", "native.oneTIMECODE": "ONE-TIME CODE", @@ -9704,13 +9704,17 @@ "native.clearProjectHistory": "Clear Project History…", "native.openAProjectToSeeHowMuchSpaceItsHistoryUses": "Open a project to see how much space its history uses.", "native.noInitialCopy": "No initial copy", + "native.noInitialCopyFolders": "No initial copy folders", "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.inNoInitialCopyEditsAreRecorded": "In {arg1}: no initial copy, edits are recorded", "native.ignoredByWildcardLineLld": "Ignored by wildcard line {arg1}", "native.ignoredByRegularExpressionLineLld": "Ignored by regular expression line {arg1}", - "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0": "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded." + "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc": "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again.", + "native.limitPerProject": "Limit per project", + "native.localHistoryLimitPerProject": "Local history limit per project", + "native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt": "Type a project-relative path to see whether local history records it." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 614f88beae..117613b94c 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -5326,7 +5326,7 @@ "native.oneFile": "单个文件:{arg1}", "native.oneNAMEValueEntryPerLine": "每行填写一个 NAME=value", "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace": "每行一个路径;相对路径以当前工作区为基准。", - "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a": "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/*` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n下方的内置规则始终生效。", + "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx3ebe586fc1": "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/*` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n列表开头是内置规则。删掉一行即可重新显示这些文件,再输入回来就会重新隐藏。", "native.onePatternPerLineLogCacheOnlyAffectsThisSearchEverywhereWindow": "每行一个表达式:`*.log`、`**/.*/`、`.*`、`cache/`。仅影响当前双 Shift 搜索窗口。", "native.oneSentenceFocusedOnTheMainChange": "聚焦主要更改的一句话", "native.oneTIMECODE": "一次性代码", @@ -9704,13 +9704,17 @@ "native.clearProjectHistory": "清空项目历史…", "native.openAProjectToSeeHowMuchSpaceItsHistoryUses": "打开项目后可查看其历史占用的空间。", "native.noInitialCopy": "不做初始副本", + "native.noInitialCopyFolders": "不做初始副本的文件夹", "native.lineLldIsNotAValidRegularExpressionAndIsSkipped": "第 {arg1} 行不是有效的正则表达式,已跳过。", "native.recorded": "会记录", "native.hiddenInTheProjectTreeNotRecorded": "已在项目树中隐藏,不记录", "native.inNoInitialCopyEditsAreRecorded": "位于 {arg1}:不做初始副本,编辑仍会记录", "native.ignoredByWildcardLineLld": "被通配符第 {arg1} 行忽略", "native.ignoredByRegularExpressionLineLld": "被正则表达式第 {arg1} 行忽略", - "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0": "本地历史会在你保存和编辑时保留文件版本,每个项目最多 100 MB。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n「不做初始副本」中的文件夹里的文件,打开项目时不会复制,编辑仍会记录。" + "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc": "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n不做初始副本:每行一个文件夹名。其中的文件在打开项目时不会复制,编辑仍会记录。列表开头是内置文件夹,删掉一行即可重新复制其中的文件。", + "native.limitPerProject": "每个项目上限", + "native.localHistoryLimitPerProject": "每个项目的本地历史上限", + "native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt": "输入项目内的相对路径,查看本地历史是否会记录它。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 860d1ec343..90e3ef5ba7 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -48584,13 +48584,13 @@ } }, { - "id": "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a", - "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. 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.", + "id": "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx3ebe586fc1", + "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. 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 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. 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.", + "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. 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 list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.", "arguments": [] } }, @@ -92802,6 +92802,50 @@ "arguments": [] } }, + { + "id": "native.noInitialCopyFolders", + "description": "Native UI: No initial copy folders", + "platforms": [ + "macos" + ], + "macos": { + "key": "No initial copy folders", + "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.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt", + "description": "Native UI: Type a project-relative path to see whether local history records it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type a project-relative path to see whether local history records it.", + "arguments": [] + } + }, { "id": "native.lineLldIsNotAValidRegularExpressionAndIsSkipped", "description": "Native UI: Line %lld is not a valid regular expression and is skipped.", @@ -92889,13 +92933,13 @@ } }, { - "id": "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul3649ff1aa0", - "description": "Native UI: Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded.", + "id": "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc", + "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again.", "platforms": [ "macos" ], "macos": { - "key": "Local history keeps versions of files as you save and edit them, up to 100 MB per project.\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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nFiles inside the folders under No initial copy are not copied when a project opens; edits to them are still recorded.", + "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again.", "arguments": [] } } diff --git a/shared/platform-feature-matrix/features/history-snapshots.json b/shared/platform-feature-matrix/features/history-snapshots.json index 23767c3c5a..c749fa3679 100644 --- a/shared/platform-feature-matrix/features/history-snapshots.json +++ b/shared/platform-feature-matrix/features/history-snapshots.json @@ -24,6 +24,6 @@ "verificationStatus": "pending" }, "owner": "Local History", - "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、生成目录、通配符、正则)、拒绝越界路径、占用统计与清空项目历史。", - "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;基线跳过 node_modules、.venv、target、.claude 等生成目录和嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。macOS 设置 › 通用 › 本地历史可配置只作用于本地历史的忽略规则(通配符或正则,每行一条,应用后生效),项目树仍显示这些文件;测试路径实时显示被哪条规则忽略,无效正则提示行号并跳过;显示本项目占用/上限,可确认后清空本项目历史。Windows 暂无忽略规则设置,但 Core 的 history.record 已支持 ignoreRules。" + "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、生成目录、通配符、正则)、拒绝越界路径、占用统计与清空项目历史。内置项可改:Rust 测试覆盖不做初始副本文件夹可新增、`!名称` 关闭内置项;隐藏路径的 `!` 条目关闭内置隐藏目录。上限可配:Rust 测试覆盖默认 100MB、配置值生效并限定在 10MB–100GB、基线预算为上限一半。", + "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;基线跳过 node_modules、.venv、target、.claude 等生成目录和嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。macOS 设置 › 通用 › 本地历史可配置只作用于本地历史的忽略规则(通配符或正则,每行一条,应用后生效),项目树仍显示这些文件;测试路径实时显示被哪条规则忽略,无效正则提示行号并跳过;显示本项目占用/上限,可确认后清空本项目历史。不做初始副本的文件夹和隐藏路径都以文本框列出内置项,删掉一行即关闭该内置项(保存为 `!条目`,后续版本新增的内置项仍会生效);用量按二进制单位显示(100 MB)。每个项目的上限可在「已用」旁用步进器调整(50–5000 MB,默认 100 MB),保存时按新上限淘汰;测试路径旁有标签说明用途。Windows 暂无忽略规则与上限设置,但 Core 的 history.record 已支持 ignoreRules。" } From 07dbbecaa36493cb525b0f04c391e76b1faca6d0 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 11:34:24 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(macos):=20=E6=9C=AC=E5=9C=B0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=B8=8A=E9=99=90=E5=8F=AF=E7=9B=B4=E6=8E=A5=E8=BE=93?= =?UTF-8?q?=E5=85=A5=EF=BC=8C=E6=B5=8B=E8=AF=95=E8=B7=AF=E5=BE=84=E5=8A=A0?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=B9=B6=E6=98=BE=E7=A4=BA=20Git=20=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E5=BF=BD=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LitheSettingsStepper 新增可选 parse,支持在数值处直接输入(失焦或回车提交,超出范围自动限定);本地历史上限启用 - 测试路径加 ⓘ 说明用途;结果分两行:本地历史是否记录、Git 是否忽略(仅作对照,本地历史不跟随 .gitignore) - history.ignorePreview 接受 workspaceRoot,返回 gitIgnored(复用 git check-ignore;无路径、工作区或仓库时为 null) - 契约文档、功能矩阵、中英 i18n 同步 --- macos/Resources/en.lproj/Localizable.strings | 10 +++- .../zh-Hans.lproj/Localizable.strings | 10 +++- .../Lithe/Core/Rust/RustCoreBridge.swift | 8 ++- .../Rust/RustLocalHistoryOperations.swift | 8 ++- .../App/LocalHistorySettingsControl.swift | 22 +++++-- .../Components/LitheSettingsControls.swift | 35 ++++++++++- .../ProjectHistoryFeatureModel.swift | 6 +- .../Ports/LocalHistoryPorts.swift | 8 ++- .../LocalHistoryModuleTests.swift | 2 +- .../LocalHistoryIgnoreRulesTests.swift | 4 +- rust/lithe-core/src/project/history.rs | 58 +++++++++++++++++-- shared/contracts/rust-core-api.md | 8 ++- shared/i18n/locales/en-US.json | 5 +- shared/i18n/locales/zh-CN.json | 5 +- shared/i18n/messages.json | 56 +++++++++++++++--- .../features/history-snapshots.json | 4 +- 16 files changed, 206 insertions(+), 43 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index fb694ad493..412766f10b 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1636,6 +1636,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."; @@ -2106,6 +2108,7 @@ "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again." = "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again."; "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"; @@ -4333,7 +4336,7 @@ "Type" = "Type"; "Type / to see commands" = "Type / to see commands"; "Type / to see options" = "Type / to see options"; -"Type a project-relative path to see whether local history records it." = "Type a project-relative path to see whether local history records it."; +"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"; @@ -6299,9 +6302,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$@"; @@ -6901,6 +6906,7 @@ "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.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc" = "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again."; "native.localHistoryLimitPerProject" = "Local history limit per project"; @@ -9043,7 +9049,7 @@ "native.turningThisOffStopsEveryAgentProcess" = "Turning this off stops every Agent process."; "native.turns" = "Turns"; "native.type" = "Type"; -"native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt" = "Type a project-relative path to see whether local history records it."; +"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"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f3f9bab920..9ba1fcf9bc 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1636,6 +1636,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 授权已取消。"; @@ -2106,6 +2108,7 @@ "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again." = "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n不做初始副本:每行一个文件夹名。其中的文件在打开项目时不会复制,编辑仍会记录。列表开头是内置文件夹,删掉一行即可重新复制其中的文件。"; "Local history limit per project" = "每个项目的本地历史上限"; "Local history work is still active." = "本地历史任务仍在进行。"; +"Local history: %@" = "本地历史:%@"; "Location" = "位置"; "Lock Worktree" = "锁定工作树"; "Locked" = "已锁定"; @@ -4333,7 +4336,7 @@ "Type" = "类型"; "Type / to see commands" = "输入 / 查看命令"; "Type / to see options" = "输入 / 查看选项"; -"Type a project-relative path to see whether local history records it." = "输入项目内的相对路径,查看本地历史是否会记录它。"; +"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" = "输入任务,用 / 选择命令,用 @ 添加文件"; @@ -6299,9 +6302,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$@"; @@ -6901,6 +6906,7 @@ "native.localHistory" = "本地历史"; "native.localHistory.variant2" = "本地历史…"; "native.localHistory.variant3" = "本地历史"; +"native.localHistoryFormat" = "本地历史:%1$@"; "native.localHistoryIsAvailableForTextFiles" = "本地历史仅适用于文本文件"; "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc" = "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n不做初始副本:每行一个文件夹名。其中的文件在打开项目时不会复制,编辑仍会记录。列表开头是内置文件夹,删掉一行即可重新复制其中的文件。"; "native.localHistoryLimitPerProject" = "每个项目的本地历史上限"; @@ -9043,7 +9049,7 @@ "native.turningThisOffStopsEveryAgentProcess" = "关闭后会停止所有 Agent 进程。"; "native.turns" = "轮次"; "native.type" = "类型"; -"native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt" = "输入项目内的相对路径,查看本地历史是否会记录它。"; +"native.typeAPathInsideTheProjectToCheckItAgainstTheRulesAboveAsYouEditThemWhetherLocalH0f31e93916" = "输入项目内的路径,随着你编辑上面的规则实时检查:本地历史是否会记录这个文件,以及 Git 是否忽略它。Git 的忽略规则不影响本地历史。"; "native.typeChanged" = "类型已更改"; "native.typeToSeeCommands" = "输入 / 查看命令"; "native.typeToSeeOptions" = "输入 / 查看选项"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 2b59d164e2..6df52735e2 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -297,6 +297,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let regexErrors: [RegexError] let builtInBaselineExcludedDirectoryNames: [String] let matched: Match? + let gitIgnored: Bool? } private struct HistoryClearPayload: Decodable, Sendable { @@ -1658,6 +1659,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let path: String? let ignoreRules: HistoryIgnoreRules let hiddenFilePatterns: [String] + let workspaceRoot: String? } private struct HistoryStorageRequest: Encodable { @@ -2770,7 +2772,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ignorePatterns: [String], ignoreRegexes: [String], baselineExcludedDirectoryNames: [String], - hiddenFilePatterns: [String] + hiddenFilePatterns: [String], + workspaceURL: URL? = nil ) -> HistoryIgnorePreviewPayload? { execute( command: "history.ignorePreview", @@ -2781,7 +2784,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { pathRegexes: ignoreRegexes, baselineExcludedDirectoryNames: baselineExcludedDirectoryNames ), - hiddenFilePatterns: hiddenFilePatterns + hiddenFilePatterns: hiddenFilePatterns, + workspaceRoot: workspaceURL?.standardizedFileURL.path ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift index 402a171c0a..52406d0ee2 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift @@ -34,13 +34,14 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { func clear(at storageURL: URL) -> Bool { core.historyClear(storageURL: storageURL) } - func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules) -> LocalHistoryIgnorePreview? { + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { guard let value = core.historyIgnorePreview( path: path, ignorePatterns: rules.ignorePatterns, ignoreRegexes: rules.ignoreRegexes, baselineExcludedDirectoryNames: rules.baselineExcludedDirectoryNames, - hiddenFilePatterns: rules.hiddenFilePatterns + hiddenFilePatterns: rules.hiddenFilePatterns, + workspaceURL: workspaceURL ) else { return nil } let matched: LocalHistoryIgnoreMatch? = switch value.matched { case nil: nil @@ -52,7 +53,8 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { return LocalHistoryIgnorePreview( regexErrors: Dictionary(value.regexErrors.map { ($0.index, $0.message) }, uniquingKeysWith: { first, _ in first }), builtInBaselineExcludedDirectoryNames: value.builtInBaselineExcludedDirectoryNames, - matched: matched + matched: matched, + gitIgnored: value.gitIgnored ) } diff --git a/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift index 883387a50e..8d525f570e 100644 --- a/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift +++ b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift @@ -40,7 +40,7 @@ struct LocalHistorySettingsControl: View { Text("Test path") .font(LitheTheme.uiFont(size: 11)) .foregroundStyle(LitheTheme.tertiaryText) - .help(Text("Type a project-relative path to see whether local history records it.")) + 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")) @@ -124,7 +124,8 @@ struct LocalHistorySettingsControl: View { step: 50, width: 126, accessibilityLabel: "Local history limit per project", - title: { "\($0) MB" } + title: { "\($0) MB" }, + parse: { Int($0.filter(\.isNumber)) } ) .help(Text("When the limit is reached, the oldest versions are removed first.")) } @@ -174,10 +175,19 @@ struct LocalHistorySettingsControl: View { private var testResult: some View { if !testPath.trimmingCharacters(in: .whitespaces).isEmpty, let preview { let recorded = preview.matched == nil || preview.matched.map(Self.isBaselineOnly) == true - Label(resultText(preview.matched), systemImage: recorded ? "checkmark.circle" : "nosign") - .font(LitheTheme.smallFont) - .foregroundStyle(recorded ? LitheTheme.success : LitheTheme.warning) - .lineLimit(2) + 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) } } 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/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index f5a0fe7c34..b39f111e16 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -99,11 +99,13 @@ public final class ProjectHistoryFeatureModel: ObservableObject { await localHistoryService?.updateVisibilityRules(rules) } - /// Validates ignore rules for the settings page; needs no open project. + /// 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) + operations.ignorePreview(path: path, rules: rules, workspaceURL: workspaceURL) }.value } diff --git a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift index d277c087a2..60cb36fab8 100644 --- a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift +++ b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift @@ -49,10 +49,14 @@ public struct LocalHistoryIgnorePreview: Equatable, Sendable { /// The built-in No initial copy folders, before the user's changes. public let builtInBaselineExcludedDirectoryNames: [String] public let matched: LocalHistoryIgnoreMatch? - public init(regexErrors: [Int: String], builtInBaselineExcludedDirectoryNames: [String], matched: LocalHistoryIgnoreMatch?) { + /// Whether Git ignores the path, shown for comparison; nil outside a repository. + public let gitIgnored: Bool? + public init(regexErrors: [Int: String], builtInBaselineExcludedDirectoryNames: [String], + matched: LocalHistoryIgnoreMatch?, gitIgnored: Bool? = nil) { self.regexErrors = regexErrors self.builtInBaselineExcludedDirectoryNames = builtInBaselineExcludedDirectoryNames self.matched = matched + self.gitIgnored = gitIgnored } } @@ -81,7 +85,7 @@ public protocol LocalHistoryOperations: Sendable { 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) -> LocalHistoryIgnorePreview? + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? } public protocol LocalHistoryWorkspaceAccess: Sendable { diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index f60e6592d4..f53ed6fe30 100644 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -74,5 +74,5 @@ private struct TestOperations: LocalHistoryOperations { 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) -> LocalHistoryIgnorePreview? { nil } + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } } diff --git a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift index 79cd034ff2..d4c93d6003 100644 --- a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift +++ b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift @@ -12,7 +12,7 @@ struct LocalHistoryIgnoreRulesTests { operations.ignorePreview(path: path, rules: LocalHistoryVisibilityRules( hiddenDirectoryNames: [], hiddenFilePatterns: hidden, ignorePatterns: patterns, ignoreRegexes: regexes, baselineExcludedDirectoryNames: baseline - )) + ), workspaceURL: nil) } @Test @@ -22,6 +22,8 @@ struct LocalHistoryIgnoreRulesTests { #expect(Array(result.regexErrors.keys) == [1]) #expect(result.matched == .regex(index: 2)) #expect(result.builtInBaselineExcludedDirectoryNames.contains("Pods")) + // Without an open project Git is not asked. + #expect(result.gitIgnored == nil) #expect(preview("logs/run.log", patterns: ["*.log"])?.matched == .pattern(index: 0)) #expect(preview("ios/Pods/A/a.h")?.matched == .baselineExcluded(directory: "Pods")) diff --git a/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index e532967a67..c29b79b9aa 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -145,6 +145,9 @@ pub struct HistoryIgnorePreviewRequest { /// 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)] @@ -181,6 +184,9 @@ pub struct HistoryIgnorePreviewResponse { /// The built-in defaults, for settings to show and edit against. pub built_in_baseline_excluded_directory_names: 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)] @@ -525,16 +531,25 @@ pub fn ignore_preview( }) }) .collect(); - let matched = match request.path.as_deref().map(str::trim) { + let (matched, git_ignored) = match request.path.as_deref().map(str::trim) { Some(path) if !path.is_empty() => { let relative = safe_relative_path(path)?; - preview_match( + 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, None), }; Ok(HistoryIgnorePreviewResponse { regex_errors, @@ -543,6 +558,7 @@ pub fn ignore_preview( .map(|name| (*name).to_string()) .collect(), matched, + git_ignored, }) } @@ -1336,6 +1352,7 @@ mod tests { path: Some(path.into()), ignore_rules: ignore_rules(&["docs/*.md"], &["(", r"\.tmp$"]), hidden_file_patterns: Vec::new(), + workspace_root: None, }) .unwrap() }; @@ -1368,8 +1385,41 @@ mod tests { 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] diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 58d06bedff..d175272fcd 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3148,11 +3148,13 @@ A matching file is not recorded, existing entries stay listed, and an invalid or empty regex never matches. `baselineExcludedDirectoryNames` adds folder names whose files get no initial copy when a project opens, on top of the built-in list; `!name` turns a built-in off. `history.ignorePreview` accepts the -same `ignoreRules`, optional `hiddenFilePatterns` and an optional relative -`path`, and returns `regexErrors` (`{ index, message }` per rejected regex), the +same `ignoreRules`, optional `hiddenFilePatterns`, an optional relative +`path` and an optional `workspaceRoot`, and returns `regexErrors` (`{ index, message }` per rejected regex), the built-in `builtInBaselineExcludedDirectoryNames`, and `matched`: `null` or an object tagged by `kind` — `hidden`, `baselineExcluded` (`directory`), `pattern` or `regex` -(`index` into its list). `history.usage` and `history.clear` accept +(`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 diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index fa048bbb4f..82d7a7a6bf 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9714,7 +9714,10 @@ "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc": "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/`.\nRegular expression: one per line, case-insensitive, matched anywhere in the project-relative path with `/` separators, for example `^build/` or `(test|spec)/`.\nNo initial copy: one folder name per line. Files inside them are not copied when a project opens; edits to them are still recorded. The list starts with the built-in folders; delete one to copy its files again.", "native.limitPerProject": "Limit per project", "native.localHistoryLimitPerProject": "Local history limit per project", - "native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt": "Type a project-relative path to see whether local history records it." + "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" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 117613b94c..0d79181d65 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9714,7 +9714,10 @@ "native.localHistoryKeepsVersionsOfFilesAsYouSaveAndEditThemUpTo100MBPerProjectIgnoreRul124b531bdc": "本地历史会在你保存和编辑时保留文件版本,每个项目默认最多 100 MB,可在「已用」旁修改上限。\n忽略规则只让文件不进本地历史,项目树里仍然显示。隐藏路径始终不记录。\n通配符:每行一条,语法与隐藏路径相同,例如 `*.log` 或 `**/generated/`。\n正则表达式:每行一条,不区分大小写,匹配以 `/` 分隔的项目相对路径的任意部分,例如 `^build/` 或 `(test|spec)/`。\n不做初始副本:每行一个文件夹名。其中的文件在打开项目时不会复制,编辑仍会记录。列表开头是内置文件夹,删掉一行即可重新复制其中的文件。", "native.limitPerProject": "每个项目上限", "native.localHistoryLimitPerProject": "每个项目的本地历史上限", - "native.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt": "输入项目内的相对路径,查看本地历史是否会记录它。" + "native.typeAPathInsideTheProjectToCheckItAgainstTheRulesAboveAsYouEditThemWhetherLocalH0f31e93916": "输入项目内的路径,随着你编辑上面的规则实时检查:本地历史是否会记录这个文件,以及 Git 是否忽略它。Git 的忽略规则不影响本地历史。", + "native.localHistoryFormat": "本地历史:{arg1}", + "native.gitIgnored": "Git:已忽略", + "native.gitNotIgnored": "Git:未忽略" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 90e3ef5ba7..a4c1ef5e61 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -92814,35 +92814,73 @@ } }, { - "id": "native.limitPerProject", - "description": "Native UI: Limit per project", + "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": "Limit per project", + "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.localHistoryLimitPerProject", - "description": "Native UI: Local history limit per project", + "id": "native.localHistoryFormat", + "description": "Native UI: Local history: %@", "platforms": [ "macos" ], "macos": { - "key": "Local history limit per project", + "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.typeAProjectRelativePathToSeeWhetherLocalHistoryRecordsIt", - "description": "Native UI: Type a project-relative path to see whether local history records it.", + "id": "native.gitNotIgnored", + "description": "Native UI: Git: not ignored", "platforms": [ "macos" ], "macos": { - "key": "Type a project-relative path to see whether local history records it.", + "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": [] } }, diff --git a/shared/platform-feature-matrix/features/history-snapshots.json b/shared/platform-feature-matrix/features/history-snapshots.json index c749fa3679..45ef049986 100644 --- a/shared/platform-feature-matrix/features/history-snapshots.json +++ b/shared/platform-feature-matrix/features/history-snapshots.json @@ -24,6 +24,6 @@ "verificationStatus": "pending" }, "owner": "Local History", - "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、生成目录、通配符、正则)、拒绝越界路径、占用统计与清空项目历史。内置项可改:Rust 测试覆盖不做初始副本文件夹可新增、`!名称` 关闭内置项;隐藏路径的 `!` 条目关闭内置隐藏目录。上限可配:Rust 测试覆盖默认 100MB、配置值生效并限定在 10MB–100GB、基线预算为上限一半。", - "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。每个项目的本地历史上限 100MB(按 4KB 分配块计),其中项目基线最多 50MB;基线跳过 node_modules、.venv、target、.claude 等生成目录和嵌套 Git 仓库。超限时在下次保存时按基线、普通编辑、带标签条目的顺序从旧到新淘汰,两端共用 Core 行为。macOS 设置 › 通用 › 本地历史可配置只作用于本地历史的忽略规则(通配符或正则,每行一条,应用后生效),项目树仍显示这些文件;测试路径实时显示被哪条规则忽略,无效正则提示行号并跳过;显示本项目占用/上限,可确认后清空本项目历史。不做初始副本的文件夹和隐藏路径都以文本框列出内置项,删掉一行即关闭该内置项(保存为 `!条目`,后续版本新增的内置项仍会生效);用量按二进制单位显示(100 MB)。每个项目的上限可在「已用」旁用步进器调整(50–5000 MB,默认 100 MB),保存时按新上限淘汰;测试路径旁有标签说明用途。Windows 暂无忽略规则与上限设置,但 Core 的 history.record 已支持 ignoreRules。" + "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、生成目录、通配符、正则)、拒绝越界路径、占用统计与清空项目历史。内置项可改:Rust 测试覆盖不做初始副本文件夹可新增、`!名称` 关闭内置项;隐藏路径的 `!` 条目关闭内置隐藏目录。上限可配:Rust 测试覆盖默认 100MB、配置值生效并限定在 10MB–100GB、基线预算为上限一半。Git 对照:Rust 测试覆盖测试路径在 Git 仓库中返回是否被 .gitignore 忽略、非仓库返回空。", + "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。" } From b391685b02254f6028fa972be2c4aa851337d2da Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 14:26:32 +0800 Subject: [PATCH 5/6] fix(history): address ignore preview and clear lifecycle review --- .../App/LocalHistorySettingsControl.swift | 17 +++- .../ProjectHistoryFeatureModel.swift | 8 +- .../Services/LocalHistoryService.swift | 18 +++- .../LocalHistoryIgnoreRulesTests.swift | 98 ++++++++++++++++++- rust/lithe-core/src/project/history.rs | 50 +++++++++- .../features/history-snapshots.json | 5 +- 6 files changed, 179 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift index 6f408426df..2e60a976bf 100644 --- a/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift +++ b/macos/Sources/Lithe/Views/App/LocalHistorySettingsControl.swift @@ -3,6 +3,12 @@ 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 { @@ -55,12 +61,13 @@ struct LocalHistorySettingsControl: View { .onChange(of: settings.localHistoryIgnoreRegexes) { _ in syncDrafts() } .task { usage = 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: testPath.trimmingCharacters(in: .whitespaces), - rules: draftRules + path: input.path, + rules: input.rules ) guard !Task.isCancelled else { return } preview = result @@ -195,7 +202,11 @@ struct LocalHistorySettingsControl: View { } } - private var previewKey: String { [testPath, patternsDraft, regexesDraft].joined(separator: "\u{0}") } + 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 { diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index 5f7cb897af..0050d4c920 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -114,12 +114,14 @@ public final class ProjectHistoryFeatureModel: ObservableObject { await localHistoryService?.usage() } - /// Deletes every snapshot of the open project. A running baseline stops - /// first so it cannot refill the storage being cleared. + /// Deletes every snapshot after cancelling and draining owned writers. public func clearHistory() async -> Bool { guard let localHistoryService else { return false } - seedTask?.cancel() + 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 = [] diff --git a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift index 9ea7bfc04a..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, @@ -31,7 +32,10 @@ actor LocalHistoryService { } func clear() -> Bool { - operations.clear(at: storageURL) + // 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 { @@ -42,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() } } @@ -121,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/LitheTests/LocalHistoryIgnoreRulesTests.swift b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift index 157c12a0a9..ec3a72786e 100644 --- a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift +++ b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift @@ -1,5 +1,5 @@ import Foundation -import LitheLocalHistoryModule +@testable import LitheLocalHistoryModule import Testing @testable import Lithe @@ -38,6 +38,74 @@ struct LocalHistoryIgnoreRulesTests { // 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 @@ -66,3 +134,31 @@ struct LocalHistoryIgnoreRulesTests { #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/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index c120ecaa6b..7ee001b126 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -650,8 +650,8 @@ fn ignored_rule(rules: &HistoryIgnoreRules, relative_path: &str) -> Option