From d8ef89a3fa7f33a1ea8024ca50e360e6d3c2e312 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 05:17:54 -0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(core):=20#1662=20WS-06=20Local=20Histo?= =?UTF-8?q?ry=20=E8=AE=B0=E5=BD=95=E6=97=B6=E6=9C=BA=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 history.planEvent,两端按 Core 规划的快照记录;Windows 保存后立即记快照,重命名/移动后接上 history.relocate。 Co-Authored-By: Claude Opus 5.5 --- .../ProjectReplacementComposition.swift | 2 +- .../WorkspaceProjectionComposition.swift | 4 +- .../Features/WorkspaceFeatureModel.swift | 2 +- .../Rust/RustCoreBridge+HistoryEvents.swift | 31 +++++++ .../Rust/RustLocalHistoryOperations.swift | 2 + .../ProjectHistoryFeatureModel.swift | 76 ++++++++++------ .../Models/LocalHistoryModels.swift | 2 + .../Ports/LocalHistoryPorts.swift | 2 + .../LocalHistoryModuleTests.swift | 2 + .../LocalHistoryIgnoreRulesTests.swift | 5 +- rust/lithe-core/src/project/history.rs | 50 ++++++++++- rust/lithe-core/src/tests/history_events.rs | 49 ++++++++++ shared/fixtures/history/event-plan-v1.json | 90 +++++++++++++++++++ .../features/history-snapshots.json | 11 ++- .../editor/stores/editor-app.store.ts | 22 +++-- .../api/local-history-api.test.ts | 74 +++++++++++++++ .../local-history/api/local-history-api.ts | 82 ++++++++++++++--- .../components/local-history-command.tsx | 9 +- 18 files changed, 458 insertions(+), 57 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustCoreBridge+HistoryEvents.swift create mode 100644 rust/lithe-core/src/tests/history_events.rs create mode 100644 shared/fixtures/history/event-plan-v1.json create mode 100644 windows/tauri/src/features/local-history/api/local-history-api.test.ts diff --git a/macos/Sources/Lithe/Application/Composition/ProjectReplacementComposition.swift b/macos/Sources/Lithe/Application/Composition/ProjectReplacementComposition.swift index 49cee6f94d..4573a5934a 100644 --- a/macos/Sources/Lithe/Application/Composition/ProjectReplacementComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/ProjectReplacementComposition.swift @@ -19,7 +19,7 @@ enum ProjectReplacementComposition { }, recordHistory: { [weak model] text, url in guard let feature = await model?.activateHistoryModule() else { return } - await feature.recordHistorySnapshot(text: text, for: url, reason: .beforeBatchReplace) + await feature.recordHistorySnapshot(text: text, for: url, event: .beforeBatchReplace) }, saveOverride: { [weak model] url, text in guard let model, diff --git a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift index dbe72c19c0..a0f1857093 100644 --- a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift @@ -48,9 +48,9 @@ enum WorkspaceProjectionComposition { }, openFile: { [weak model] url in model?.openFile(url) }, notify: { [weak notification] message in notification?.show(message) }, - recordHistory: { [weak model] url, reason in + recordHistory: { [weak model] url, event in guard let feature = await model?.activateHistoryModule() else { return } - await feature.recordHistory(containedIn: url, reason: reason) + await feature.recordHistory(containedIn: url, event: event) }, relocateHistory: { [weak model] source, destination in guard let feature = await model?.activateHistoryModule() else { return } diff --git a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift index 30cc5eb94b..e4aff32417 100644 --- a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift @@ -37,7 +37,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel { restoreSession: @escaping @MainActor @Sendable (WorkspaceSession, [URL]) async -> Void, openFile: @escaping @MainActor @Sendable (URL) -> Void, notify: @escaping @MainActor @Sendable (String) -> Void, - recordHistory: @escaping @MainActor @Sendable (URL, LocalHistoryReason) async -> Void, + recordHistory: @escaping @MainActor @Sendable (URL, LocalHistoryEvent) async -> Void, relocateHistory: @escaping @MainActor @Sendable (URL, URL) async -> Void, moveTrackedItem: @escaping @MainActor @Sendable (URL, URL) async -> Bool = { _, _ in false }, relocateOpenDocuments: @escaping @MainActor @Sendable (URL, URL) -> Void, diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+HistoryEvents.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+HistoryEvents.swift new file mode 100644 index 0000000000..e19347c32f --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+HistoryEvents.swift @@ -0,0 +1,31 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +extension RustCoreBridge { + /// Snapshots Core records for an editing event; nil means Core rejected it, which was + /// logged here. + func historyPlanEvent(_ event: LocalHistoryEvent) -> [LocalHistorySnapshotPlan]? { + struct Request: Encodable { let event: String } + struct Snapshot: Decodable { let source: String; let reason: String } + struct Response: Decodable { let snapshots: [Snapshot] } + let result: Result = executeResult( + command: "history.planEvent", payload: Request(event: event.rawValue) + ) + switch result { + case .success(let response): + let plans = response.snapshots.compactMap { snapshot -> LocalHistorySnapshotPlan? in + guard let source = LocalHistorySnapshotPlan.Source(rawValue: snapshot.source), + let reason = LocalHistoryReason(rawValue: snapshot.reason) else { return nil } + return LocalHistorySnapshotPlan(source: source, reason: reason) + } + return plans.count == response.snapshots.count ? plans : nil + case .failure(let error): + coreLogSink?.emit(AppCoreFallbackSelected( + error: LitheModuleAPI.LogFailure(domain: "RustCore", code: error.code), + stage: "history.planEvent" + )) + return nil + } + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift index 7dfea2d2ec..33a80570e6 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift @@ -27,6 +27,8 @@ 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 planEvent(_ event: LocalHistoryEvent) -> [LocalHistorySnapshotPlan]? { core.historyPlanEvent(event) } + func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { core.historyUsage(storageURL: storageURL, limitBytes: limitBytes).map { LocalHistoryUsage(usedBytes: $0.usedBytes, limitBytes: $0.limitBytes) } } diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index d1d9451c39..1964f3cd33 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -148,15 +148,26 @@ public final class ProjectHistoryFeatureModel: ObservableObject { } } + /// The snapshots Core plans for `event`; none when Core rejects the event. + private func plannedSnapshots(for event: LocalHistoryEvent) -> [LocalHistorySnapshotPlan] { + guard let plan = localHistoryOperations.planEvent(event) else { + logSink?.emit(AppHistoryOperationFailed(error: LocalHistoryPlanError.rejected, stage: "plan")) + return [] + } + return plan + } + public func recordSave(_ document: LocalHistoryDocumentSnapshot, previousText: String) { guard let localHistoryService else { return } let currentText = document.text let url = document.url + let plan = plannedSnapshots(for: .save) startBackgroundTask { [logSink] in - do { _ = try await localHistoryService.record(text: previousText, for: url, reason: .saved) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } - do { _ = try await localHistoryService.record(text: currentText, for: url, reason: .saved) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + for snapshot in plan { + let text = snapshot.source == .beforeChange ? previousText : currentText + do { _ = try await localHistoryService.record(text: text, for: url, reason: snapshot.reason) } + catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + } } } @@ -164,22 +175,35 @@ public final class ProjectHistoryFeatureModel: ObservableObject { guard let localHistoryService else { return } let text = document.text let url = document.url + let plan = plannedSnapshots(for: .unsavedDiscard).filter { $0.source == .editorBuffer } startBackgroundTask { [logSink] in - do { _ = try await localHistoryService.record(text: text, for: url, reason: .unsavedDiscard) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + for snapshot in plan { + do { _ = try await localHistoryService.record(text: text, for: url, reason: snapshot.reason) } + catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + } } } + /// Records `text` as the file's content before `event` changes it. public func recordHistorySnapshot( text: String, for fileURL: URL, - reason: LocalHistoryReason + event: LocalHistoryEvent ) async { - do { _ = try await localHistoryService?.record(text: text, for: fileURL, reason: reason) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + for snapshot in plannedSnapshots(for: event) where snapshot.source == .beforeChange { + do { _ = try await localHistoryService?.record(text: text, for: fileURL, reason: snapshot.reason) } + catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + } + } + + /// Records the file's current content on disk as `event` plans. + private func recordFile(_ url: URL, event: LocalHistoryEvent) async { + for snapshot in plannedSnapshots(for: event) { + await localHistoryService?.recordFiles([url], reason: snapshot.reason) + } } - public func recordHistory(containedIn url: URL, reason: LocalHistoryReason) async { + public func recordHistory(containedIn url: URL, event: LocalHistoryEvent) async { guard let localHistoryService else { return } let files: [URL] if projectFilesProvider().contains(where: { $0.standardizedFileURL == url.standardizedFileURL }) { @@ -187,7 +211,9 @@ public final class ProjectHistoryFeatureModel: ObservableObject { } else { files = projectFilesProvider().filter { urlContains(url, child: $0) } } - await localHistoryService.recordFiles(files, reason: reason) + for snapshot in plannedSnapshots(for: event) { + await localHistoryService.recordFiles(files, reason: snapshot.reason) + } } public func relocateHistory(from sourceURL: URL, to destinationURL: URL) async { @@ -198,8 +224,11 @@ public final class ProjectHistoryFeatureModel: ObservableObject { public func recordExternalChanges(_ paths: [URL]) { guard let localHistoryService else { return } let changedFiles = paths.filter { workspaceAccess.fileExists(at: $0) } + let plan = plannedSnapshots(for: .externalChange) startBackgroundTask { - await localHistoryService.recordFiles(changedFiles, reason: .externalChange) + for snapshot in plan { + await localHistoryService.recordFiles(changedFiles, reason: snapshot.reason) + } } } @@ -277,15 +306,9 @@ public final class ProjectHistoryFeatureModel: ObservableObject { let restoredText = try await localHistoryService.content(for: entry) let document = documentsProvider().first { $0.url == request.fileURL } if let document { - do { _ = try await localHistoryService.record( - text: document.text, - for: request.fileURL, - reason: .restored - ) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + await recordHistorySnapshot(text: document.text, for: request.fileURL, event: .restore) } else { - do { _ = try await localHistoryService.recordFile(at: request.fileURL, reason: .restored) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + await recordFile(request.fileURL, event: .restore) } guard workspaceAccess.writeFile( restoredText, @@ -312,15 +335,9 @@ public final class ProjectHistoryFeatureModel: ObservableObject { let restoredText = try await localHistoryService.content(for: entry) let document = documentsProvider().first { $0.url == targetURL } if let document { - do { _ = try await localHistoryService.record( - text: document.text, - for: targetURL, - reason: .restored - ) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + await recordHistorySnapshot(text: document.text, for: targetURL, event: .restore) } else if workspaceAccess.fileExists(at: targetURL) { - do { _ = try await localHistoryService.recordFile(at: targetURL, reason: .restored) } - catch { logSink?.emit(AppHistoryOperationFailed(error: error, stage: "record")) } + await recordFile(targetURL, event: .restore) } guard workspaceAccess.writeFile( restoredText, @@ -447,3 +464,6 @@ public final class ProjectHistoryFeatureModel: ObservableObject { return childPath == parentPath || childPath.hasPrefix(parentPath + "/") } } + +/// Core refused to plan a history event, so nothing is recorded for it. +private enum LocalHistoryPlanError: Error { case rejected } diff --git a/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift b/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift index 387d687921..8e796f5001 100644 --- a/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift +++ b/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift @@ -20,6 +20,8 @@ public struct LocalHistoryEntry: Identifiable, Codable, Hashable, Sendable { } public typealias LocalHistoryReason = LitheCoreContracts.LocalHistoryReason +public typealias LocalHistoryEvent = LitheCoreContracts.LocalHistoryEvent +public typealias LocalHistorySnapshotPlan = LitheCoreContracts.LocalHistorySnapshotPlan public struct LocalHistoryRequest: Identifiable { public let id = UUID() diff --git a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift index 7ce8dd699f..dcc76091a9 100644 --- a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift +++ b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift @@ -79,6 +79,8 @@ 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 + /// The snapshots Core records for `event` (`history.planEvent`); nil when Core rejects it. + func planEvent(_ event: LocalHistoryEvent) -> [LocalHistorySnapshotPlan]? func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? func clear(at storageURL: URL) -> Bool func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index 9e17b1d8cf..de63a76c09 100644 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -104,6 +104,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 planEvent(_ event: LocalHistoryEvent) -> [LocalHistorySnapshotPlan]? { [] } func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } func clear(at storageURL: URL) -> Bool { false } func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } @@ -127,6 +128,7 @@ private final class BatchOperations: LocalHistoryOperations, @unchecked Sendable func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? { [] } func content(at storageURL: URL, contentPath: String) -> String? { nil } func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } + func planEvent(_ event: LocalHistoryEvent) -> [LocalHistorySnapshotPlan]? { [] } func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } func clear(at storageURL: URL) -> Bool { false } func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } diff --git a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift index 302eb713e1..c8e7122586 100644 --- a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift +++ b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift @@ -104,7 +104,7 @@ struct LocalHistoryIgnoreRulesTests { #expect(await feature.clearHistory()) #expect(!feature.hasActiveModuleWork) #expect(operations.paths.isEmpty) - await feature.recordHistorySnapshot(text: "after clear", for: document.url, reason: .saved) + await feature.recordHistorySnapshot(text: "after clear", for: document.url, event: .beforeBatchReplace) #expect(operations.paths == ["saved"]) } @@ -163,6 +163,9 @@ private final class ClearingHistoryOperations: LocalHistoryOperations, @unchecke 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 planEvent(_ event: LocalHistoryEvent) -> [LocalHistorySnapshotPlan]? { + [LocalHistorySnapshotPlan(source: .beforeChange, reason: .saved)] + } func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } func compareTexts(old: String, current: String) -> [LocalHistoryDiffRow]? { [] } diff --git a/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index 4f77542259..66d0b2ff41 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -433,6 +433,54 @@ pub fn entries(request: HistoryEntriesRequest) -> Result Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Request { + event: String, + } + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history event request") + .with_details(error.to_string()) + })?; + let plan: &[(&str, &str)] = match request.event.as_str() { + "save" | "autoSave" => &[("beforeChange", "saved"), ("afterChange", "saved")], + "restore" => &[("beforeChange", "restored")], + "manual" => &[("afterChange", "saved")], + "unsavedDiscard" => &[("editorBuffer", "unsavedDiscard")], + "externalChange" => &[("afterChange", "externalChange")], + "beforeRename" => &[("beforeChange", "beforeRename")], + "beforeBatchReplace" => &[("beforeChange", "beforeBatchReplace")], + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unknown history event", + )) + } + }; + Ok(serde_json::json!({ + "snapshots": plan + .iter() + .map(|(source, reason)| serde_json::json!({ "source": source, "reason": reason })) + .collect::>() + })) +} + /// Reads one validated snapshot content file as UTF-8. pub fn content(request: HistoryContentRequest) -> Result { let storage = storage_root(&request.storage_root)?; @@ -1118,7 +1166,7 @@ fn monotonic_nonce() -> u128 { .unwrap_or_default() } -fn is_valid_reason(value: &str) -> bool { +pub(crate) fn is_valid_reason(value: &str) -> bool { matches!( value, "projectBaseline" diff --git a/rust/lithe-core/src/tests/history_events.rs b/rust/lithe-core/src/tests/history_events.rs new file mode 100644 index 0000000000..be5a5b36ba --- /dev/null +++ b/rust/lithe-core/src/tests/history_events.rs @@ -0,0 +1,49 @@ +use crate::execute_json; +use serde_json::{json, Value}; + +fn execute(payload: Value) -> Value { + serde_json::from_str(&execute_json( + &json!({"id": "history-event", "command": "history.planEvent", "payload": payload}) + .to_string(), + )) + .expect("core response should be JSON") +} + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/history/event-plan-v1.json" + )) + .expect("history event fixture should be JSON") +} + +#[test] +fn event_plans_match_the_shared_fixture() { + for case in fixture()["events"].as_array().unwrap() { + let response = execute(json!({"event": case["event"]})); + assert_eq!(response["ok"], true, "{}: {response}", case["event"]); + assert_eq!( + response["data"]["snapshots"], case["snapshots"], + "{}", + case["event"] + ); + } +} + +#[test] +fn every_planned_reason_is_accepted_by_the_recorder() { + for case in fixture()["events"].as_array().unwrap() { + for snapshot in case["snapshots"].as_array().unwrap() { + let reason = snapshot["reason"].as_str().unwrap(); + assert!(crate::project::is_valid_reason(reason), "{reason}"); + } + } +} + +#[test] +fn unknown_events_are_rejected() { + for event in fixture()["unknownEvents"].as_array().unwrap() { + let response = execute(json!({"event": event})); + assert_eq!(response["ok"], false, "{event}"); + assert_eq!(response["error"]["code"], "invalid_request", "{event}"); + } +} diff --git a/shared/fixtures/history/event-plan-v1.json b/shared/fixtures/history/event-plan-v1.json new file mode 100644 index 0000000000..3ee66f9ee9 --- /dev/null +++ b/shared/fixtures/history/event-plan-v1.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": 1, + "events": [ + { + "event": "save", + "snapshots": [ + { + "source": "beforeChange", + "reason": "saved" + }, + { + "source": "afterChange", + "reason": "saved" + } + ] + }, + { + "event": "autoSave", + "snapshots": [ + { + "source": "beforeChange", + "reason": "saved" + }, + { + "source": "afterChange", + "reason": "saved" + } + ] + }, + { + "event": "restore", + "snapshots": [ + { + "source": "beforeChange", + "reason": "restored" + } + ] + }, + { + "event": "manual", + "snapshots": [ + { + "source": "afterChange", + "reason": "saved" + } + ] + }, + { + "event": "unsavedDiscard", + "snapshots": [ + { + "source": "editorBuffer", + "reason": "unsavedDiscard" + } + ] + }, + { + "event": "externalChange", + "snapshots": [ + { + "source": "afterChange", + "reason": "externalChange" + } + ] + }, + { + "event": "beforeRename", + "snapshots": [ + { + "source": "beforeChange", + "reason": "beforeRename" + } + ] + }, + { + "event": "beforeBatchReplace", + "snapshots": [ + { + "source": "beforeChange", + "reason": "beforeBatchReplace" + } + ] + } + ], + "unknownEvents": [ + "", + "rename", + "Save" + ] +} diff --git a/shared/platform-feature-matrix/features/history-snapshots.json b/shared/platform-feature-matrix/features/history-snapshots.json index 0d4d3f034d..aaf1158ae0 100644 --- a/shared/platform-feature-matrix/features/history-snapshots.json +++ b/shared/platform-feature-matrix/features/history-snapshots.json @@ -16,7 +16,9 @@ "macos/Tests/LitheTests/LocalHistorySettingsTests.swift", "rust/lithe-core/src/tests/project.rs", "shared/fixtures/history/relocation-v1.json", - "macos/Tests/LitheTests/LitheCoreLogicTests.swift" + "macos/Tests/LitheTests/LitheCoreLogicTests.swift", + "shared/fixtures/history/event-plan-v1.json", + "macos/Sources/Lithe/Core/Rust/RustCoreBridge+HistoryEvents.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -25,12 +27,15 @@ "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", + "shared/fixtures/history/event-plan-v1.json", + "rust/lithe-core/src/project/history.rs", + "windows/tauri/src/features/local-history/api/local-history-api.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Local History", - "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 macOS 外部变更和目录操作按批记录历史,仅首个成功快照触发全库过期清理;验证跳过文件、连续批次、取消及单文件保存,4000 文件变化后 CPU 回落且历史仍可读取。 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、通配符含内置文件夹、正则)、拒绝越界路径、占用统计与清空项目历史。内置项可改:Rust 测试覆盖内置文件夹以 `**/名称/` 通配符规则生效、命中时报告该规则、`!**/名称/` 或 `!名称` 关闭内置项后重新记录;隐藏路径的 `!` 条目关闭内置隐藏目录。上限可配:Rust 测试覆盖默认 100MB、配置值生效并限定在 10MB–100GB、基线预算为上限一半。Git 对照:Rust 测试覆盖测试路径在 Git 仓库中返回是否被 .gitignore 忽略、非仓库返回空。 PR #1615 回归:关闭内置隐藏项和历史忽略项后,添加无关通配符仍可预览及记录;清空前取消并等待已有保存和外部变更任务,清空代次拒绝在途批次续写,清空后新保存可记录;隐藏路径变化纳入预览请求身份并重新计算结果。 容量输入仅接受完整非负整数与可选 MB 后缀,指数、负数、字母、溢出等无效输入不改变已保存值;回车和失焦均验证恢复或范围限定。清空失败显示中英错误与需再次确认的重试入口,回读剩余用量;重试成功清除错误并显示新用量。 PR #1597 历史迁移回归:文件与目录已有历史后排除索引,再重命名或移动,按历史元数据迁移精确路径和后代,保留内容、ID及相对后缀,同名前缀兄弟不变;工作区回归使用真实文件操作与空索引候选,不能为迁移历史重新纳入索引。目标平台原生历史界面验收仍待完成。", + "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 macOS 外部变更和目录操作按批记录历史,仅首个成功快照触发全库过期清理;验证跳过文件、连续批次、取消及单文件保存,4000 文件变化后 CPU 回落且历史仍可读取。 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。存储上限:Rust 测试覆盖基线跳过生成目录与嵌套仓库、保存时清理旧版本留下的排除目录基线、超过 100MB 先淘汰旧基线再淘汰编辑和标签、基线预算 50MB。忽略规则:Rust 测试覆盖通配符与正则忽略新记录但保留已有历史、正则不区分大小写且无效或空正则不匹配、预览报告无效正则行和命中规则(隐藏、通配符含内置文件夹、正则)、拒绝越界路径、占用统计与清空项目历史。内置项可改:Rust 测试覆盖内置文件夹以 `**/名称/` 通配符规则生效、命中时报告该规则、`!**/名称/` 或 `!名称` 关闭内置项后重新记录;隐藏路径的 `!` 条目关闭内置隐藏目录。上限可配:Rust 测试覆盖默认 100MB、配置值生效并限定在 10MB–100GB、基线预算为上限一半。Git 对照:Rust 测试覆盖测试路径在 Git 仓库中返回是否被 .gitignore 忽略、非仓库返回空。 PR #1615 回归:关闭内置隐藏项和历史忽略项后,添加无关通配符仍可预览及记录;清空前取消并等待已有保存和外部变更任务,清空代次拒绝在途批次续写,清空后新保存可记录;隐藏路径变化纳入预览请求身份并重新计算结果。 容量输入仅接受完整非负整数与可选 MB 后缀,指数、负数、字母、溢出等无效输入不改变已保存值;回车和失焦均验证恢复或范围限定。清空失败显示中英错误与需再次确认的重试入口,回读剩余用量;重试成功清除错误并显示新用量。 PR #1597 历史迁移回归:文件与目录已有历史后排除索引,再重命名或移动,按历史元数据迁移精确路径和后代,保留内容、ID及相对后缀,同名前缀兄弟不变;工作区回归使用真实文件操作与空索引候选,不能为迁移历史重新纳入索引。目标平台原生历史界面验收仍待完成。 记录时机统一:两端都按 Core 的 history.planEvent 记录,保存与自动保存先记被替换的文本、写入成功后立即再记保存后的文本,恢复先记当前文本。Windows 重命名或移动文件及目录(含拖放与剪切粘贴)后调用 history.relocate 迁移历史,验证旧路径无残留、新路径沿用原有快照。", "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。" } diff --git a/windows/tauri/src/features/editor/stores/editor-app.store.ts b/windows/tauri/src/features/editor/stores/editor-app.store.ts index c9eb144e6e..5d45542bc6 100644 --- a/windows/tauri/src/features/editor/stores/editor-app.store.ts +++ b/windows/tauri/src/features/editor/stores/editor-app.store.ts @@ -11,7 +11,10 @@ import { createStore } from "zustand/vanilla"; import { extensionRegistry } from "@/extensions/registry/extension-registry"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { emitGitChanged } from "@/features/git/events/git-events"; -import { recordLocalHistoryFile } from "@/features/local-history/api/local-history-api"; +import { + recordLocalHistoryFile, + type LocalHistoryPhase, +} from "@/features/local-history/api/local-history-api"; import { isEditorContent, type EditorContent, @@ -42,12 +45,17 @@ import { trackBufferHistoryChange } from "./buffer-history-tracking"; import { useBufferStore } from "./buffer.store"; import { queueEditorViewContentChange } from "./view.store"; -async function recordLocalHistoryBeforeWrite( +/** + * Records the local-history snapshots Core plans for a save. The text being replaced is + * recorded before the write and the saved text right after it, as macOS does. + */ +async function recordSaveHistory( path: string, - reason: "save" | "auto-save" | "restore", + event: "save" | "autoSave", + phase: LocalHistoryPhase, ): Promise { try { - await recordLocalHistoryFile(path, reason); + await recordLocalHistoryFile(path, event, phase); } catch (error) { console.warn("Failed to record local history:", error); } @@ -396,7 +404,7 @@ async function saveEditorBufferById( claimedSave = await claimDocumentSave(workspaceId, saveBuffer); if (!claimedSave) return "cancelled"; - await recordLocalHistoryBeforeWrite(activeBuffer.path, "save"); + await recordSaveHistory(activeBuffer.path, "save", "beforeChange"); const expectedContent = saveBuffer.acknowledgedDiskContent === undefined ? saveBuffer.savedContent : saveBuffer.acknowledgedDiskContent; const persisted = await persistClaimedDocument( workspaceId, @@ -412,6 +420,7 @@ async function saveEditorBufferById( return "cancelled"; } await finishDocumentSave(workspaceId, claimedSave, contentToSave); + await recordSaveHistory(activeBuffer.path, "save", "afterChange"); try { const { LspClient } = await import("@/features/editor/lsp/lsp-client"); @@ -721,7 +730,7 @@ export const createEditorAppStore = ( traceDocumentSaveCancellation(autoSaveContext, "awaited-write-not-persisted"); return; } - await recordLocalHistoryBeforeWrite(target.path, "auto-save"); + await recordSaveHistory(target.path, "autoSave", "beforeChange"); const expectedContent = latestBeforeSave.acknowledgedDiskContent === undefined ? latestBeforeSave.savedContent : latestBeforeSave.acknowledgedDiskContent; @@ -742,6 +751,7 @@ export const createEditorAppStore = ( return; } await finishDocumentSave(workspaceId, claim, text); + await recordSaveHistory(target.path, "autoSave", "afterChange"); const rootFolderPath = useFileSystemStore .getStore(workspaceId) diff --git a/windows/tauri/src/features/local-history/api/local-history-api.test.ts b/windows/tauri/src/features/local-history/api/local-history-api.test.ts new file mode 100644 index 0000000000..651efd1431 --- /dev/null +++ b/windows/tauri/src/features/local-history/api/local-history-api.test.ts @@ -0,0 +1,74 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as tauriPath from "@tauri-apps/api/path"; +import * as coreClient from "@/core/lithe-core-client"; +import { useProjectStore } from "@/features/window/stores/project.store"; +import { recordLocalHistoryFile, relocateLocalHistory } from "./local-history-api"; + +type Call = { command: string; payload: Record }; +const calls: Call[] = []; +const spies: Array<{ mockRestore: () => void }> = []; + +// Core's answer for `history.planEvent("save")`, as pinned by shared/fixtures/history/event-plan-v1.json. +const savePlan = { + snapshots: [ + { source: "beforeChange", reason: "saved" }, + { source: "afterChange", reason: "saved" }, + ], +}; + +function installCore(plan: unknown) { + calls.length = 0; + const previousRoot = useProjectStore.getState().rootFolderPath; + useProjectStore.setState({ rootFolderPath: "C:/workspace" }); + spies.push({ mockRestore: () => useProjectStore.setState({ rootFolderPath: previousRoot }) }); + spies.push(spyOn(tauriPath, "appDataDir").mockResolvedValue("C:/appdata")); + spies.push(spyOn(tauriPath, "join").mockImplementation((async (...parts: string[]) => parts.join("/")) as typeof tauriPath.join)); + spies.push( + spyOn(coreClient, "executeCore").mockImplementation((async (request: { command: string; payload: Record }) => { + calls.push({ command: request.command, payload: request.payload }); + if (request.command === "history.planEvent") return { ok: true, data: plan }; + if (request.command === "history.record") { + return { ok: true, data: { id: "entry", timestamp: 1, relativePath: "a.txt", reason: request.payload.reason, contentPath: "p", byteCount: 1 } }; + } + return { ok: true, data: { relocated: true } }; + }) as unknown as typeof coreClient.executeCore), + ); +} + +afterEach(() => { + while (spies.length) spies.pop()!.mockRestore(); +}); + +test("a save records the replaced text before the write and the saved text after it", async () => { + installCore(savePlan); + await recordLocalHistoryFile("C:/workspace/a.txt", "save", "beforeChange"); + await recordLocalHistoryFile("C:/workspace/a.txt", "save", "afterChange"); + expect(calls.map((call) => call.command)).toEqual([ + "history.planEvent", "history.record", "history.planEvent", "history.record", + ]); + expect(calls.filter((call) => call.command === "history.planEvent").map((call) => call.payload)).toEqual([ + { event: "save" }, { event: "save" }, + ]); + expect(calls.filter((call) => call.command === "history.record").map((call) => call.payload.reason)).toEqual([ + "saved", "saved", + ]); +}); + +test("a phase the event does not plan records nothing", async () => { + installCore({ snapshots: [{ source: "beforeChange", reason: "restored" }] }); + expect(await recordLocalHistoryFile("C:/workspace/a.txt", "restore", "afterChange")).toBeNull(); + expect(calls.map((call) => call.command)).toEqual(["history.planEvent"]); +}); + +test("a rename moves retained history by workspace-relative paths", async () => { + installCore(savePlan); + await relocateLocalHistory("C:/workspace/src/a.txt", "C:/workspace/lib/a.txt"); + const relocate = calls.find((call) => call.command === "history.relocate")!; + expect(relocate.payload).toMatchObject({ sourcePath: "src/a.txt", destinationPath: "lib/a.txt" }); +}); + +test("paths outside the workspace have no history to move", async () => { + installCore(savePlan); + await relocateLocalHistory("C:/workspace/a.txt", "D:/elsewhere/a.txt"); + expect(calls).toEqual([]); +}); diff --git a/windows/tauri/src/features/local-history/api/local-history-api.ts b/windows/tauri/src/features/local-history/api/local-history-api.ts index 6e1dec8da4..8f1bf9674e 100644 --- a/windows/tauri/src/features/local-history/api/local-history-api.ts +++ b/windows/tauri/src/features/local-history/api/local-history-api.ts @@ -52,26 +52,51 @@ const listCoreEntries = async ( return coreData(response).entries; }; +/** Editing events that record snapshots; Core's `history.planEvent` owns what each records. */ +export type LocalHistoryEvent = "save" | "autoSave" | "restore" | "manual"; +/** Whether a snapshot holds the text before the event changed the file or after it did. */ +export type LocalHistoryPhase = "beforeChange" | "afterChange"; + +interface PlannedSnapshot { + source: "beforeChange" | "afterChange" | "editorBuffer"; + reason: string; +} + +/** + * Records the snapshots Core plans for `event` in `phase`, reading the file from disk. + * Returns the last entry created; a label is applied to it. + */ export const recordLocalHistoryFile = async ( path: string, - reason: "save" | "auto-save" | "restore" | "manual" = "save", + event: LocalHistoryEvent, + phase: LocalHistoryPhase, label?: string, ): Promise => { const context = await historyContext(path); - const coreReason = reason === "restore" ? "restored" : "saved"; - const recorded = coreData( - await executeCore({ + const { snapshots } = coreData( + await executeCore<{ snapshots: PlannedSnapshot[] }>({ id: crypto.randomUUID(), - command: "history.record", - payload: { - workspaceRoot: context.workspaceRoot, - storageRoot: context.storageRoot, - path: context.relativePath, - reason: coreReason, - ...hiddenPathRules(), - }, + command: "history.planEvent", + payload: { event }, }), ); + let recorded: CoreHistoryEntry | null = null; + for (const snapshot of snapshots.filter((planned) => planned.source === phase)) { + const entry = coreData( + await executeCore({ + id: crypto.randomUUID(), + command: "history.record", + payload: { + workspaceRoot: context.workspaceRoot, + storageRoot: context.storageRoot, + path: context.relativePath, + reason: snapshot.reason, + ...hiddenPathRules(), + }, + }), + ); + recorded = entry ?? recorded; + } if (!recorded) return null; if (!label) return toLocalEntry(recorded); return toLocalEntry( @@ -90,6 +115,39 @@ export const recordLocalHistoryFile = async ( ); }; +/** + * Moves retained history to a renamed or moved file or directory. Paths outside the + * active workspace have no history to move. + */ +export const relocateLocalHistory = async ( + sourcePath: string, + destinationPath: string, +): Promise => { + const workspaceRoot = useProjectStore.getState().rootFolderPath; + if ( + !workspaceRoot || + !pathStartsWithRoot(sourcePath, workspaceRoot) || + !pathStartsWithRoot(destinationPath, workspaceRoot) + ) { + return; + } + const relative = (path: string) => getRelativePath(path, workspaceRoot).replace(/\\/g, "/"); + const sourceRelative = relative(sourcePath); + const destinationRelative = relative(destinationPath); + if (!sourceRelative || !destinationRelative) return; + coreData( + await executeCore<{ relocated: boolean }>({ + id: crypto.randomUUID(), + command: "history.relocate", + payload: { + storageRoot: await workspaceHistoryStorage(workspaceRoot), + sourcePath: sourceRelative, + destinationPath: destinationRelative, + }, + }), + ); +}; + export const listLocalHistoryFile = async (path: string): Promise => (await listCoreEntries(await historyContext(path))).map(toLocalEntry); diff --git a/windows/tauri/src/features/local-history/components/local-history-command.tsx b/windows/tauri/src/features/local-history/components/local-history-command.tsx index 649e136d4b..92702111a8 100644 --- a/windows/tauri/src/features/local-history/components/local-history-command.tsx +++ b/windows/tauri/src/features/local-history/components/local-history-command.tsx @@ -185,7 +185,12 @@ function FileLocalHistoryCommandContent({ if (label === null) return; try { - const entry = await recordLocalHistoryFile(targetPath, "manual", label.trim() || undefined); + const entry = await recordLocalHistoryFile( + targetPath, + "manual", + "afterChange", + label.trim() || undefined, + ); if (!entry) { toast.info(t("localHistory.noChanges")); return; @@ -299,7 +304,7 @@ function FileLocalHistoryCommandContent({ try { const content = await readLocalHistoryEntry(targetPath, entry.id); - await recordLocalHistoryFile(targetPath, "restore"); + await recordLocalHistoryFile(targetPath, "restore", "beforeChange"); await writeFile(targetPath, content); const bufferStore = useBufferStore.getState(); From ba80b79dbed69001713b1c5a81e46fc461515325 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 05:18:04 -0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(core):=20#1662=20WS-11=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E8=A7=92=E8=89=B2=E3=80=81=E5=8F=96=E6=B6=88=E6=8E=92?= =?UTF-8?q?=E9=99=A4=E4=B8=8E=E8=A7=84=E5=88=99=E8=BF=81=E7=A7=BB=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=E5=88=B0=20Core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 workspace.reduceDirectoryAction 与 workspace.relocateDirectoryRules,删除两端各自的实现。 Co-Authored-By: Claude Opus 5.5 --- .../Rust/RustCoreBridge+WorkspaceRules.swift | 74 ++++ .../Workspace/WorkspaceIndexingPolicy.swift | 27 ++ .../src/project/directory_actions.rs | 344 ++++++++++++++++++ .../lithe-core/src/tests/directory_actions.rs | 100 +++++ .../workspace/directory-rules-v1.json | 118 ++++++ .../workspace-indexing-exclusions.json | 11 +- .../hooks/use-file-explorer-context-menu.tsx | 19 +- .../stores/directory-marks.test.ts | 332 ++++++++--------- .../file-explorer/stores/directory-marks.ts | 188 ++++------ 9 files changed, 900 insertions(+), 313 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustCoreBridge+WorkspaceRules.swift create mode 100644 rust/lithe-core/src/project/directory_actions.rs create mode 100644 rust/lithe-core/src/tests/directory_actions.rs create mode 100644 shared/fixtures/workspace/directory-rules-v1.json diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+WorkspaceRules.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+WorkspaceRules.swift new file mode 100644 index 0000000000..5faab7d92a --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+WorkspaceRules.swift @@ -0,0 +1,74 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +extension RustCoreBridge { + /// Name for a copy of `name` that avoids the sibling names in `existing`. Core owns the + /// rule; nil means Core rejected the request, which was logged here. + func copyName(for name: String, isDirectory: Bool, existing: [String], duplicate: Bool) -> String? { + struct Item: Encodable { let name: String; let isDirectory: Bool } + struct DuplicateRequest: Encodable { + let name: String; let isDirectory: Bool; let existing: [String] + } + struct PasteRequest: Encodable { let items: [Item]; let existing: [String] } + struct DuplicateResponse: Decodable { let name: String } + struct PasteResponse: Decodable { let names: [String] } + if duplicate { + let result: Result = executeResult( + command: "workspace.planDuplicate", + payload: DuplicateRequest(name: name, isDirectory: isDirectory, existing: existing) + ) + return logged(result, stage: "workspace.planDuplicate")?.name + } + let result: Result = executeResult( + command: "workspace.planPaste", + payload: PasteRequest(items: [Item(name: name, isDirectory: isDirectory)], existing: existing) + ) + return logged(result, stage: "workspace.planPaste")?.names.first + } + + /// Applies a directory action to the persisted rules in Core. + func reduceDirectoryAction( + _ action: WorkspaceDirectoryAction, at path: String, rules: WorkspaceDirectoryRules + ) -> WorkspaceDirectoryReduction? { + struct Action: Encodable { let kind: String; let role: String? } + struct Request: Encodable { let path: String; let action: Action; let rules: WorkspaceDirectoryRules } + struct Response: Decodable { let changed: Bool; let rules: WorkspaceDirectoryRules } + let wire: Action + switch action { + case .exclude: wire = Action(kind: "exclude", role: nil) + case .cancelExclusion: wire = Action(kind: "cancelExclusion", role: nil) + case .setRole(let role): wire = Action(kind: "setRole", role: role.rawValue) + } + let result: Result = executeResult( + command: "workspace.reduceDirectoryAction", + payload: Request(path: path, action: wire, rules: rules) + ) + return logged(result, stage: "workspace.reduceDirectoryAction") + .map { WorkspaceDirectoryReduction(changed: $0.changed, rules: $0.rules) } + } + + /// Migrates the persisted rules after a move (or a delete, without a destination). + func relocateDirectoryRules( + _ rules: WorkspaceDirectoryRules, moving source: String, to destination: String? + ) -> WorkspaceDirectoryRules? { + struct Request: Encodable { let source: String; let destination: String?; let rules: WorkspaceDirectoryRules } + struct Response: Decodable { let rules: WorkspaceDirectoryRules } + let result: Result = executeResult( + command: "workspace.relocateDirectoryRules", + payload: Request(source: source, destination: destination, rules: rules) + ) + return logged(result, stage: "workspace.relocateDirectoryRules")?.rules + } + + private func logged(_ result: Result, stage: String) -> Value? { + switch result { + case .success(let value): return value + case .failure(let error): + coreLogSink?.emit(AppCoreFallbackSelected( + error: LitheModuleAPI.LogFailure(domain: "RustCore", code: error.code), stage: stage + )) + return nil + } + } +} diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift index e4c71c78f4..5310c81b25 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceIndexingPolicy.swift @@ -7,6 +7,33 @@ package enum WorkspaceDirectoryAction: Equatable, Sendable { case cancelExclusion } +/// The persisted directory rules of one workspace root, exactly as Rust Core's +/// directory rules model (`workspace.reduceDirectoryAction`) reads and returns them. +package struct WorkspaceDirectoryRules: Codable, Equatable, Sendable { + package var marks: [String: WorkspaceDirectoryMark] + package var includedDirectories: [String] + package var previousRoles: [String: WorkspaceDirectoryMark] + package init( + marks: [String: WorkspaceDirectoryMark], + includedDirectories: [String], + previousRoles: [String: WorkspaceDirectoryMark] + ) { + self.marks = marks + self.includedDirectories = includedDirectories + self.previousRoles = previousRoles + } +} + +/// Core's answer to a directory action: the full state to persist, and whether it differs. +package struct WorkspaceDirectoryReduction: Equatable, Sendable { + package let changed: Bool + package let rules: WorkspaceDirectoryRules + package init(changed: Bool, rules: WorkspaceDirectoryRules) { + self.changed = changed + self.rules = rules + } +} + /// Stable application failures. Presentation owns translation; adapters retain diagnostics. package enum WorkspaceIndexingFailure: Error, Equatable, Sendable { case invalidSettings, readFailed, writeFailed, configureFailed, workspaceUnavailable, permissionDenied diff --git a/rust/lithe-core/src/project/directory_actions.rs b/rust/lithe-core/src/project/directory_actions.rs new file mode 100644 index 0000000000..5b79d96114 --- /dev/null +++ b/rust/lithe-core/src/project/directory_actions.rs @@ -0,0 +1,344 @@ +//! Directory roles, exclusion and rule migration for the Project view. +//! +//! A workspace stores three related preferences keyed by workspace-relative directory: +//! the role of a directory (`marks`), the directories that opt back in to indexing +//! although Git ignores them (`includedDirectories`) and the role a directory had before it +//! was excluded (`previousRoles`). Hosts persist these values in their own preference +//! storage and do not interpret them. Every change goes through Core: +//! +//! - `workspace.reduceDirectoryAction` applies "set role", "exclude" and "cancel +//! exclusion" to a copy of that state. +//! - `workspace.relocateDirectoryRules` moves the keys of a subtree after a rename or +//! move, drops them after a delete, or hands them to the state of another workspace +//! root. +//! +//! Directory keys are compared as paths: with `caseInsensitive` set, keys that differ only +//! by case name the same directory. Stored keys keep the spelling they were written with. +//! The sequence contract is `shared/fixtures/workspace/directory-actions-v1.json`; the +//! request and response shapes are pinned by `directory-rules-v1.json`. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +/// Largest number of keys one request may carry. +const MAX_ENTRIES: usize = 100_000; +const ROLES: [&str; 6] = [ + "plain", + "sources", + "resources", + "excluded", + "module", + "package", +]; + +#[derive(Deserialize, Serialize, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// The persisted rule state of one workspace root. +struct Rules { + /// Directory role by workspace-relative path; a missing key is a plain directory. + #[serde(default)] + marks: BTreeMap, + /// Directories that stay indexed although Git ignores them. + #[serde(default)] + included_directories: Vec, + /// The role to restore when a manual exclusion is canceled. + #[serde(default)] + previous_roles: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum Action { + SetRole { role: String }, + Exclude, + CancelExclusion, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReduceRequest { + /// Workspace-relative directory; `.` is the workspace root. + path: String, + action: Action, + #[serde(default)] + case_insensitive: bool, + rules: Rules, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RelocateRequest { + /// Directory that was moved or deleted, relative to the root that owns `rules`. + source: String, + /// Where it went, relative to the root that owns `target` (or `rules` without a + /// target); absent when the directory was deleted. + #[serde(default)] + destination: Option, + #[serde(default)] + case_insensitive: bool, + rules: Rules, + /// Rules of the destination root when the move crosses workspace roots. + #[serde(default)] + target: Option, +} + +/// Applies one directory action (`workspace.reduceDirectoryAction`). +/// +/// Returns `{ changed, rules }`, where `rules` is the complete state to persist. `changed` is false when the action has no effect, including canceling an +/// exclusion that a manually excluded ancestor still enforces; the state is then the +/// input. Excluding remembers the replaced role and canceling restores it; changing a role +/// keeps include exceptions; excluding deletes only the directory's own exception. +pub(crate) fn reduce_command(payload: Value) -> Result { + let request: ReduceRequest = parse(payload)?; + validate_path(&request.path)?; + validate_rules(&request.rules)?; + let ident = Identity(request.case_insensitive); + let before = request.rules.clone(); + let mut rules = request.rules; + let path = request.path; + let same = |key: &String| ident.key(key) == ident.key(&path); + match request.action { + Action::CancelExclusion => { + let path_ident = ident.key(&path); + let blocked = rules.marks.iter().any(|(key, role)| { + role == "excluded" + && ident.key(key) != path_ident + && (key == "." || path_ident.starts_with(&format!("{}/", ident.key(key)))) + }); + if blocked { + return Ok(response(false, before)); + } + let current = take_same(&mut rules.marks, &same); + let previous = take_same(&mut rules.previous_roles, &same); + let restored = if current.as_deref() == Some("excluded") { + previous + } else { + current + }; + if let Some(role) = restored { + rules.marks.insert(path.clone(), role); + } + if !rules.included_directories.iter().any(same) { + rules.included_directories.push(path); + } + } + Action::Exclude => { + let current = take_same(&mut rules.marks, &same); + if let Some(role) = current.filter(|role| role != "excluded") { + rules.previous_roles.retain(|key, _| !same(key)); + rules.previous_roles.insert(path.clone(), role); + } + rules.marks.insert(path.clone(), "excluded".into()); + rules.included_directories.retain(|key| !same(key)); + } + Action::SetRole { role } => { + if !ROLES.contains(&role.as_str()) || role == "excluded" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Directory role is not assignable", + )); + } + take_same(&mut rules.marks, &same); + rules.marks.insert(path, role); + } + } + let changed = rules != before; + Ok(response(changed, rules)) +} + +/// Migrates directory rules after a move or delete (`workspace.relocateDirectoryRules`). +/// +/// Returns `{ rules, target }`. Every key at or below `source` is removed from `rules`. +/// With a `destination` the key is rewritten under it, in `target` for a move across +/// workspace roots and in `rules` otherwise; without one the keys are dropped. `target` is +/// absent from the response unless the request had one. +pub(crate) fn relocate_command(payload: Value) -> Result { + let request: RelocateRequest = parse(payload)?; + validate_path(&request.source)?; + if let Some(destination) = &request.destination { + validate_path(destination)?; + } + validate_rules(&request.rules)?; + if let Some(target) = &request.target { + validate_rules(target)?; + if request.destination.is_none() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A target root needs a destination", + )); + } + } + let ident = Identity(request.case_insensitive); + let source_key = ident.key(&request.source); + let source_len = request.source.chars().count(); + let under = |key: &str| { + let key = ident.key(key); + request.source == "." || key == source_key || key.starts_with(&format!("{source_key}/")) + }; + let moved_key = |key: &str| -> Option { + let destination = request.destination.as_deref()?; + let tail: String = if request.source == "." { + if key == "." { + String::new() + } else { + format!("/{key}") + } + } else { + key.chars().skip(source_len).collect() + }; + Some(if destination == "." { + let trimmed = tail.trim_start_matches('/'); + if trimmed.is_empty() { + ".".into() + } else { + trimmed.into() + } + } else { + format!("{destination}{tail}") + }) + }; + let mut remaining = request.rules.clone(); + let mut destination_rules = request.target.clone(); + let moved = |remaining: &mut Rules, into: &mut Rules| { + let marks = std::mem::take(&mut remaining.marks); + for (key, role) in marks { + if under(&key) { + if let Some(moved) = moved_key(&key) { + into.marks.insert(moved, role); + } + } else { + remaining.marks.insert(key, role); + } + } + let previous = std::mem::take(&mut remaining.previous_roles); + for (key, role) in previous { + if under(&key) { + if let Some(moved) = moved_key(&key) { + into.previous_roles.insert(moved, role); + } + } else { + remaining.previous_roles.insert(key, role); + } + } + let included = std::mem::take(&mut remaining.included_directories); + for key in included { + if under(&key) { + if let Some(moved) = moved_key(&key) { + push_unique(&mut into.included_directories, moved, &ident); + } + } else { + remaining.included_directories.push(key); + } + } + }; + match destination_rules.as_mut() { + Some(target) => moved(&mut remaining, target), + None => { + // Same root: moved entries are collected first so a key that lands on + // another moved key cannot be processed twice. + let mut collected = Rules::default(); + moved(&mut remaining, &mut collected); + remaining.marks.extend(collected.marks); + remaining.previous_roles.extend(collected.previous_roles); + for key in collected.included_directories { + push_unique(&mut remaining.included_directories, key, &ident); + } + } + } + Ok(json!({ "rules": remaining, "target": destination_rules })) +} + +struct Identity(bool); + +impl Identity { + fn key(&self, path: &str) -> String { + if self.0 { + path.to_lowercase() + } else { + path.to_owned() + } + } +} + +fn push_unique(list: &mut Vec, key: String, ident: &Identity) { + if !list + .iter() + .any(|existing| ident.key(existing) == ident.key(&key)) + { + list.push(key); + } +} + +/// Removes every entry the predicate matches and returns the value of the exact key +/// when one matched, otherwise of the first match. +fn take_same( + map: &mut BTreeMap, + same: &impl Fn(&String) -> bool, +) -> Option { + let keys = map + .keys() + .filter(|key| same(key)) + .cloned() + .collect::>(); + let mut first = None; + for key in keys { + let value = map.remove(&key); + first = first.or(value); + } + first +} + +fn response(changed: bool, rules: Rules) -> Value { + json!({ "changed": changed, "rules": rules }) +} + +fn parse(payload: Value) -> Result { + serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid directory rules request") + .with_details(error.to_string()) + }) +} + +fn validate_path(path: &str) -> Result<(), CoreError> { + if path.is_empty() + || path.starts_with('/') + || path.contains(['\\', ':', '\0']) + || path.split('/').any(|part| part == "..") + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Directory rule paths must be workspace-relative", + )); + } + Ok(()) +} + +fn validate_rules(rules: &Rules) -> Result<(), CoreError> { + if rules.marks.len() + rules.included_directories.len() + rules.previous_roles.len() + > MAX_ENTRIES + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many directory rules", + )); + } + let paths = rules + .marks + .keys() + .chain(&rules.included_directories) + .chain(rules.previous_roles.keys()); + for path in paths { + validate_path(path)?; + } + for role in rules.marks.values().chain(rules.previous_roles.values()) { + if !ROLES.contains(&role.as_str()) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unknown directory role", + )); + } + } + Ok(()) +} diff --git a/rust/lithe-core/src/tests/directory_actions.rs b/rust/lithe-core/src/tests/directory_actions.rs new file mode 100644 index 0000000000..bbed28a00d --- /dev/null +++ b/rust/lithe-core/src/tests/directory_actions.rs @@ -0,0 +1,100 @@ +use crate::execute_json; +use serde_json::{json, Value}; + +fn execute(command: &str, payload: Value) -> Value { + serde_json::from_str(&execute_json( + &json!({"id": "directory-actions", "command": command, "payload": payload}).to_string(), + )) + .expect("core response should be JSON") +} + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/directory-rules-v1.json" + )) + .expect("directory rules fixture should be JSON") +} + +/// Sorts include lists so the comparison does not depend on insertion order. +fn normalized(mut value: Value) -> Value { + fn visit(value: &mut Value) { + match value { + Value::Object(map) => { + if let Some(Value::Array(list)) = map.get_mut("includedDirectories") { + list.sort_by_key(|item| item.as_str().unwrap_or_default().to_owned()); + } + map.values_mut().for_each(visit); + } + Value::Array(list) => list.iter_mut().for_each(visit), + _ => {} + } + } + visit(&mut value); + value +} + +#[test] +fn reduce_matches_the_shared_fixture() { + for case in fixture()["reduce"].as_array().unwrap() { + let response = execute("workspace.reduceDirectoryAction", case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["id"]); + assert_eq!( + normalized(response["data"].clone()), + normalized(case["response"].clone()), + "{}", + case["id"] + ); + } +} + +#[test] +fn relocate_matches_the_shared_fixture() { + for case in fixture()["relocate"].as_array().unwrap() { + let response = execute("workspace.relocateDirectoryRules", case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["id"]); + assert_eq!( + normalized(response["data"].clone()), + normalized(case["response"].clone()), + "{}", + case["id"] + ); + } +} + +#[test] +fn invalid_requests_are_rejected() { + for case in fixture()["invalid"].as_array().unwrap() { + let response = execute(case["command"].as_str().unwrap(), case["request"].clone()); + assert_eq!(response["ok"], false, "{case}"); + assert_eq!(response["error"]["code"], "invalid_request", "{case}"); + } +} + +/// The action-sequence contract shared with the Git-ignore search tests: replaying every +/// step through Core must reach the role and include state the fixture lists. +#[test] +fn action_sequences_reach_the_documented_role_and_inclusion() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/directory-actions-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut rules = json!({"marks": {}, "includedDirectories": [], "previousRoles": {}}); + for step in case["steps"].as_array().unwrap() { + let response = execute( + "workspace.reduceDirectoryAction", + json!({"path": "archive", "action": step["action"], "rules": rules}), + ); + assert_eq!(response["ok"], true, "{}: {response}", case["id"]); + rules = response["data"]["rules"].clone(); + let role = rules["marks"]["archive"].as_str().unwrap_or("plain"); + let included = rules["includedDirectories"] + .as_array() + .unwrap() + .iter() + .any(|path| path == "archive"); + assert_eq!(role, step["role"], "{}", case["id"]); + assert_eq!(included, step["included"], "{}", case["id"]); + } + } +} diff --git a/shared/fixtures/workspace/directory-rules-v1.json b/shared/fixtures/workspace/directory-rules-v1.json new file mode 100644 index 0000000000..a8e377c26f --- /dev/null +++ b/shared/fixtures/workspace/directory-rules-v1.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 1, + "reduce": [ + { + "id": "exclude-remembers-the-role", + "request": {"path": "src", "action": {"kind": "exclude"}, + "rules": {"marks": {"src": "sources"}, "includedDirectories": ["src"], "previousRoles": {}}}, + "response": {"changed": true, + "rules": {"marks": {"src": "excluded"}, "includedDirectories": [], "previousRoles": {"src": "sources"}}} + }, + { + "id": "cancel-restores-the-remembered-role", + "request": {"path": "src", "action": {"kind": "cancelExclusion"}, + "rules": {"marks": {"src": "excluded"}, "includedDirectories": [], "previousRoles": {"src": "sources"}}}, + "response": {"changed": true, + "rules": {"marks": {"src": "sources"}, "includedDirectories": ["src"], "previousRoles": {}}} + }, + { + "id": "cancel-without-a-remembered-role-clears-the-mark", + "request": {"path": "build", "action": {"kind": "cancelExclusion"}, + "rules": {"marks": {"build": "excluded"}, "includedDirectories": [], "previousRoles": {}}}, + "response": {"changed": true, + "rules": {"marks": {}, "includedDirectories": ["build"], "previousRoles": {}}} + }, + { + "id": "cancel-git-exclusion-keeps-the-current-role", + "request": {"path": "gen", "action": {"kind": "cancelExclusion"}, + "rules": {"marks": {"gen": "resources"}, "includedDirectories": [], "previousRoles": {"gen": "sources"}}}, + "response": {"changed": true, + "rules": {"marks": {"gen": "resources"}, "includedDirectories": ["gen"], "previousRoles": {}}} + }, + { + "id": "manual-ancestor-exclusion-cannot-be-overridden", + "request": {"path": "vendor/lib", "action": {"kind": "cancelExclusion"}, + "rules": {"marks": {"vendor": "excluded"}, "includedDirectories": [], "previousRoles": {}}}, + "response": {"changed": false, + "rules": {"marks": {"vendor": "excluded"}, "includedDirectories": [], "previousRoles": {}}} + }, + { + "id": "workspace-root-exclusion-blocks-descendants", + "request": {"path": "src", "action": {"kind": "cancelExclusion"}, + "rules": {"marks": {".": "excluded"}, "includedDirectories": [], "previousRoles": {}}}, + "response": {"changed": false, + "rules": {"marks": {".": "excluded"}, "includedDirectories": [], "previousRoles": {}}} + }, + { + "id": "setting-a-role-keeps-the-include-exception", + "request": {"path": "gen", "action": {"kind": "setRole", "role": "sources"}, + "rules": {"marks": {}, "includedDirectories": ["gen"], "previousRoles": {}}}, + "response": {"changed": true, + "rules": {"marks": {"gen": "sources"}, "includedDirectories": ["gen"], "previousRoles": {}}} + }, + { + "id": "setting-the-same-role-changes-nothing", + "request": {"path": "gen", "action": {"kind": "setRole", "role": "sources"}, + "rules": {"marks": {"gen": "sources"}, "includedDirectories": [], "previousRoles": {}}}, + "response": {"changed": false, + "rules": {"marks": {"gen": "sources"}, "includedDirectories": [], "previousRoles": {}}} + }, + { + "id": "case-insensitive-paths-name-one-directory", + "request": {"path": "SRC", "caseInsensitive": true, "action": {"kind": "cancelExclusion"}, + "rules": {"marks": {"src": "excluded"}, "includedDirectories": [], "previousRoles": {"src": "package"}}}, + "response": {"changed": true, + "rules": {"marks": {"SRC": "package"}, "includedDirectories": ["SRC"], "previousRoles": {}}} + }, + { + "id": "case-sensitive-paths-are-distinct", + "request": {"path": "SRC", "action": {"kind": "exclude"}, + "rules": {"marks": {"src": "sources"}, "includedDirectories": ["src"], "previousRoles": {}}}, + "response": {"changed": true, + "rules": {"marks": {"SRC": "excluded", "src": "sources"}, "includedDirectories": ["src"], "previousRoles": {}}} + } + ], + "relocate": [ + { + "id": "rename-moves-the-subtree", + "request": {"source": "src", "destination": "lib", + "rules": {"marks": {"src": "sources", "src/main": "module", "srcs": "plain"}, + "includedDirectories": ["src/gen", "other"], "previousRoles": {"src/old": "resources"}}}, + "response": {"rules": {"marks": {"lib": "sources", "lib/main": "module", "srcs": "plain"}, + "includedDirectories": ["other", "lib/gen"], "previousRoles": {"lib/old": "resources"}}, "target": null} + }, + { + "id": "delete-drops-the-subtree", + "request": {"source": "src", + "rules": {"marks": {"src": "sources", "src/main": "module", "lib": "plain"}, + "includedDirectories": ["src/gen"], "previousRoles": {"src": "plain"}}}, + "response": {"rules": {"marks": {"lib": "plain"}, "includedDirectories": [], "previousRoles": {}}, "target": null} + }, + { + "id": "case-insensitive-source-match", + "request": {"source": "Src", "destination": "lib", "caseInsensitive": true, + "rules": {"marks": {"src/main": "module"}, "includedDirectories": [], "previousRoles": {}}}, + "response": {"rules": {"marks": {"lib/main": "module"}, "includedDirectories": [], "previousRoles": {}}, "target": null} + }, + { + "id": "move-to-the-workspace-root", + "request": {"source": "a/b", "destination": ".", + "rules": {"marks": {"a/b": "sources", "a/b/c": "module"}, "includedDirectories": [], "previousRoles": {}}}, + "response": {"rules": {"marks": {".": "sources", "c": "module"}, "includedDirectories": [], "previousRoles": {}}, "target": null} + }, + { + "id": "move-across-workspace-roots", + "request": {"source": "pkg", "destination": "vendor/pkg", "caseInsensitive": true, + "rules": {"marks": {"pkg": "package", "keep": "plain"}, "includedDirectories": ["pkg/gen"], "previousRoles": {"pkg": "sources"}}, + "target": {"marks": {"vendor": "plain"}, "includedDirectories": ["vendor/pkg/gen"], "previousRoles": {}}}, + "response": {"rules": {"marks": {"keep": "plain"}, "includedDirectories": [], "previousRoles": {}}, + "target": {"marks": {"vendor": "plain", "vendor/pkg": "package"}, "includedDirectories": ["vendor/pkg/gen"], "previousRoles": {"vendor/pkg": "sources"}}} + } + ], + "invalid": [ + {"command": "workspace.reduceDirectoryAction", "request": {"path": "../x", "action": {"kind": "exclude"}, "rules": {}}}, + {"command": "workspace.reduceDirectoryAction", "request": {"path": "x", "action": {"kind": "setRole", "role": "excluded"}, "rules": {}}}, + {"command": "workspace.reduceDirectoryAction", "request": {"path": "x", "action": {"kind": "exclude"}, "rules": {"marks": {"y": "bogus"}}}}, + {"command": "workspace.relocateDirectoryRules", "request": {"source": "x", "rules": {}, "target": {}}} + ] +} diff --git a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json index 8ebc9e895c..3df01c4545 100644 --- a/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json +++ b/shared/platform-feature-matrix/features/workspace-indexing-exclusions.json @@ -24,7 +24,9 @@ "macos/Tests/LitheTests/GitStatusObservationTests.swift", "macos/Tests/LitheTests/RunEntryPointTests.swift", "shared/fixtures/workspace/directory-actions-v1.json", - "macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift" + "macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift", + "rust/lithe-core/src/project/directory_actions.rs", + "shared/fixtures/workspace/directory-rules-v1.json" ] }, "windows": { @@ -56,7 +58,10 @@ "windows/tauri/crates/project/src/indexing_refresh.rs", "windows/tauri/crates/project/src/git_watcher.rs", "windows/tauri/src/features/git/runtime/git-metadata-watch-host.tsx", - "windows/tauri/src/features/editor/lsp/mounted-lsp-documents.ts" + "windows/tauri/src/features/editor/lsp/mounted-lsp-documents.ts", + "rust/lithe-core/src/project/directory_actions.rs", + "shared/fixtures/workspace/directory-rules-v1.json" ] - } + }, + "notes": "目录角色、排除时记住原角色、取消排除时恢复,以及移动、重命名或删除目录后的规则迁移,都由 Core 的 workspace.reduceDirectoryAction 和 workspace.relocateDirectoryRules 执行;两端只负责读取和保存各自的偏好存储。Windows 现在在拖放移动和剪切粘贴之后同样迁移这些规则。" } diff --git a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx index 71d9c682df..acc55cbcef 100644 --- a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx +++ b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx @@ -94,7 +94,7 @@ interface UseFileExplorerContextMenuOptions { isIndexExcluded?: (path: string) => boolean; markForPath?: (path: string) => DirectoryMark | undefined; canExcludeDirectory?: (path: string) => boolean; - markDirectory?: (path: string, action: DirectoryAction) => void; + markDirectory?: (path: string, action: DirectoryAction) => void | Promise; onFileSelect: (path: string, isDir: boolean) => void | string | Promise; onCreateNewFileInDirectory?: ( directoryPath: string, @@ -875,14 +875,16 @@ export function useFileExplorerContextMenu({ : `project.mark.${mark}`, ), onClick: () => { - try { - markDirectory(contextMenu.path, excluded ? { kind: "cancelExclusion" } - : mark === "excluded" ? { kind: "exclude" } : { kind: "setRole", role: mark }); - } catch (error) { - toast.error(t("project.markFailed"), { - description: t(error instanceof IndexingSettingsError ? `indexing.${error.code}` : "indexing.writeFailed"), + const action: DirectoryAction = excluded ? { kind: "cancelExclusion" } + : mark === "excluded" ? { kind: "exclude" } : { kind: "setRole", role: mark }; + // The async wrapper calls `markDirectory` synchronously and turns both a thrown + // error and a rejected promise into the same failure toast. + void (async () => markDirectory(contextMenu.path, action))() + .catch((error: unknown) => { + toast.error(t("project.markFailed"), { + description: t(error instanceof IndexingSettingsError ? `indexing.${error.code}` : "indexing.writeFailed"), + }); }); - } }, })), }); @@ -949,6 +951,7 @@ export function useFileExplorerContextMenu({ onClick: () => { void pasteIntoExplorerDirectory({ targetDirectory: contextMenu.path, + workspaceRoots: rootFolderPath ? [rootFolderPath] : undefined, createFileInDirectory: onCreateNewFileInDirectory, refreshDirectory: onRefreshDirectory, onJavaClassCreated: (fileName) => { diff --git a/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts b/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts index 3b262295ed..33caa10e61 100644 --- a/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts +++ b/windows/tauri/src/features/file-explorer/stores/directory-marks.test.ts @@ -1,5 +1,6 @@ import { toast } from "sonner"; -import fixture from "../../../../../../shared/fixtures/workspace/directory-actions-v1.json"; +import rulesFixture from "../../../../../../shared/fixtures/workspace/directory-rules-v1.json"; +import * as native from "@/platform/tauri-core"; import type { DirectoryAction } from "./directory-marks"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; @@ -17,19 +18,57 @@ beforeEach(() => { }); afterEach(() => restore()); -test("cancelling exclusion follows Windows path identity after a case-only rename", () => { - markWorkspaceDirectory(["C:/workspace"], "C:/workspace/src", { kind: "setRole", role: "sources" }); - markWorkspaceDirectory(["C:/workspace"], "C:/workspace/src", { kind: "exclude" }); - markWorkspaceDirectory(["C:/workspace"], "C:/workspace/SRC", { kind: "cancelExclusion" }); - expect(readDirectoryMarks("C:/workspace")).toEqual({ SRC: "sources" }); - expect(readIndexingSettings("C:/workspace").previousRoles).toEqual({}); - expect(readIndexingSettings("C:/workspace").includedDirectories).toEqual(["SRC"]); - markWorkspaceDirectory(["C:/workspace"], "C:/workspace/src", { kind: "exclude" }); - expect(readDirectoryMarks("C:/workspace")).toEqual({ src: "excluded" }); - expect(readIndexingSettings("C:/workspace").includedDirectories).toEqual([]); - markWorkspaceDirectory(["C:/workspace"], "C:/workspace/SRC/child", { kind: "cancelExclusion" }); - expect(readIndexingSettings("C:/workspace").includedDirectories).toEqual([]); +type Rules = { marks: Record; includedDirectories: string[]; previousRoles: Record }; +interface ReduceCase { + id: string; + request: { path: string; action: DirectoryAction; caseInsensitive?: boolean; rules: Partial }; + response: { changed: boolean; rules: Partial }; +} +interface RelocateCase { + id: string; + request: { source: string; destination?: string; caseInsensitive?: boolean; rules: Partial; target?: Partial }; + response: { rules: Partial; target: Partial | null }; +} +const reduceCases = rulesFixture.reduce as unknown as ReduceCase[]; +const relocateCases = rulesFixture.relocate as unknown as RelocateCase[]; +const emptyRules = (rules: Partial | undefined): Rules => ({ + marks: {}, includedDirectories: [], previousRoles: {}, ...rules, }); +// Requests are compared as Core reads them: omitted optional fields mean their defaults. +const sortKeys = (value: unknown): unknown => + Array.isArray(value) ? value.map(sortKeys) + : value && typeof value === "object" + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, sortKeys(item)])) + : value; +const canonical = (command: string, request: Record) => JSON.stringify(sortKeys([command, { + ...request, + caseInsensitive: request.caseInsensitive ?? false, + rules: emptyRules(request.rules as Partial), + ...(request.target ? { target: emptyRules(request.target as Partial) } : {}), +}])); +const seed = (root: string, rules: Partial) => { + const full = emptyRules(rules); + window.localStorage.setItem(`lithe.workspace-directory-marks.${root.toLowerCase()}`, JSON.stringify({ version: 1, marks: full.marks })); + writeIndexingSettings(root, { gitIgnoreEnabled: false, includedDirectories: full.includedDirectories, previousRoles: full.previousRoles }); +}; +const stored = (root: string): Rules => ({ + marks: readDirectoryMarks(root), + includedDirectories: readIndexingSettings(root).includedDirectories, + previousRoles: readIndexingSettings(root).previousRoles, +}); +/** Replays the fixture's Core answer for the request Windows built; any other request fails. */ +const replayFixture = () => { + const answers = new Map(); + for (const item of reduceCases) + answers.set(canonical("workspace.reduceDirectoryAction", item.request), item.response); + for (const item of relocateCases) + answers.set(canonical("workspace.relocateDirectoryRules", item.request), item.response); + return spyOn(native, "invoke").mockImplementation((async (command: string, payload?: unknown) => { + const answer = answers.get(canonical(command, payload as Record)); + if (!answer) throw new Error(`Windows sent a request the fixture does not list: ${command} ${JSON.stringify(payload)}`); + return answer; + }) as typeof native.invoke); +}; test("directory mark read failures use stable classes and preserve the original data", () => { for (const raw of ["broken", "null", "{}", '{"version":1,"marks":[]}']) { @@ -45,75 +84,6 @@ test("directory mark read failures use stable classes and preserve the original expect(() => readDirectoryMarks("C:/workspace", failing)).toThrow(IndexingSettingsError); }); -test("directory marks migrate a renamed subtree and remove a deleted subtree", () => { - window.localStorage.setItem( - storageKey, - JSON.stringify({ - version: 1, - marks: { src: "sources", "src/a": "package", assets: "resources" }, - }), - ); - migrateDirectoryMarks("C:/workspace", "c:\\WORKSPACE\\SRC", "C:/workspace/code"); - expect(readDirectoryMarks("c:\\WORKSPACE\\")).toEqual({ - code: "sources", - "code/a": "package", - assets: "resources", - }); - migrateDirectoryMarks("C:/workspace", "C:/workspace/code"); - expect(readDirectoryMarks("C:/workspace")).toEqual({ assets: "resources" }); - expect(readDirectoryMarks("C:/other")).toEqual({}); -}); - -test.each([ - "wsl://Ubuntu/project", - "remote://host/project", - "/project", - "//wsl.localhost/Ubuntu/project", - "//wsl$/Ubuntu/project", -])("directory marks preserve case-sensitive paths and workspace storage: %s", (root) => { - const storageKey = `lithe.workspace-directory-marks.${root}`; - const otherRoot = root.replace(/project$/, "Project"); - window.localStorage.setItem( - storageKey, - JSON.stringify({ - version: 1, - marks: { src: "sources", Src: "resources", "Src/a": "package" }, - }), - ); - const otherRaw = JSON.stringify({ version: 1, marks: { src: "excluded" } }); - window.localStorage.setItem(`lithe.workspace-directory-marks.${otherRoot}`, otherRaw); - - migrateDirectoryMarks(root, `${root}/src`, `${root}/new`); - - expect(readDirectoryMarks(root)).toEqual({ - new: "sources", - Src: "resources", - "Src/a": "package", - }); - migrateDirectoryMarks(root, `${root}/Src`); - expect(readDirectoryMarks(root)).toEqual({ new: "sources" }); - expect(readDirectoryMarks(otherRoot)).toEqual({ src: "excluded" }); - expect(window.localStorage.getItem(`lithe.workspace-directory-marks.${otherRoot}`)).toBe( - otherRaw, - ); -}); - -test("directory marks keep native Windows and UNC paths case-insensitive", () => { - const root = "//server/share/workspace"; - window.localStorage.setItem( - "lithe.workspace-directory-marks.//server/share/workspace", - JSON.stringify({ version: 1, marks: { src: "sources", "src/a": "package" } }), - ); - - migrateDirectoryMarks(root, "\\\\SERVER\\SHARE\\WORKSPACE\\SRC", "//SERVER/SHARE/WORKSPACE/New"); - - expect(readDirectoryMarks(root)).toEqual({ New: "sources", "New/a": "package" }); - expect(readDirectoryMarks("\\\\?\\UNC\\SERVER\\SHARE\\WORKSPACE\\")).toEqual({ - New: "sources", - "New/a": "package", - }); -}); - test("volume-root marks keep their existing storage key", () => { window.localStorage.setItem( "lithe.workspace-directory-marks.c:", @@ -122,9 +92,12 @@ test("volume-root marks keep their existing storage key", () => { expect(readDirectoryMarks("C:/")).toEqual({ src: "sources" }); }); -test("corrupt storage remains intact and legal dotted directory names are accepted", () => { +test("corrupt storage remains intact and legal dotted directory names are accepted", async () => { window.localStorage.setItem(storageKey, "broken"); - expect(() => migrateDirectoryMarks("C:/workspace", "C:/workspace/src")).toThrow(); + const invoke = spyOn(native, "invoke"); + await expect(migrateDirectoryMarks("C:/workspace", "C:/workspace/src")).rejects.toThrow(); + expect(invoke).not.toHaveBeenCalled(); + invoke.mockRestore(); expect(window.localStorage.getItem(storageKey)).toBe("broken"); window.localStorage.setItem( storageKey, @@ -138,27 +111,6 @@ test("corrupt storage remains intact and legal dotted directory names are accept expect(() => readDirectoryMarks("C:/workspace")).toThrow(); }); -test("Git include exceptions and remembered roles follow directory moves and deletion", () => { - writeIndexingSettings("C:/workspace", { - gitIgnoreEnabled: false, - includedDirectories: ["src/examples", "assets"], - previousRoles: { "src/examples": "sources" }, - }); - migrateDirectoryMarks("C:/workspace", "C:/workspace/src", "C:/workspace/code"); - expect(readIndexingSettings("C:/workspace")).toEqual({ - gitIgnoreEnabled: false, - includedDirectories: ["code/examples", "assets"], - previousRoles: { "code/examples": "sources" }, - }); - migrateDirectoryMarks("C:/workspace", "C:/workspace/code"); - expect(readIndexingSettings("C:/workspace")).toEqual({ - gitIgnoreEnabled: false, - includedDirectories: ["assets"], - previousRoles: {}, - }); -}); - - test("indexing preferences do not overwrite marks for a similarly named workspace", () => { window.localStorage.setItem("lithe.workspace-directory-marks.c:/workspace.indexing", JSON.stringify({version: 1, marks: {src: "sources"}})); writeIndexingSettings("C:/workspace", {gitIgnoreEnabled: true, includedDirectories: [], previousRoles: {}}); @@ -166,78 +118,94 @@ test("indexing preferences do not overwrite marks for a similarly named workspac }); -test("added and nested roots own exclusion marks and Git include exceptions", () => { - const roots = ["C:/main", "D:/added", "D:/added/nested"]; - writeIndexingSettings("C:/main", {gitIgnoreEnabled: false, includedDirectories: ["keep"], previousRoles: {}}); - markWorkspaceDirectory(roots, "d:\\ADDED\\build", { kind: "setRole", role: "sources" }); - markWorkspaceDirectory(roots, "d:\\ADDED\\build", { kind: "exclude" }); - expect(readDirectoryMarks("D:/added")).toEqual({build: "excluded"}); - expect(readIndexingSettings("D:/added").previousRoles).toEqual({build: "sources"}); - markWorkspaceDirectory(roots, "D:/added/build", { kind: "cancelExclusion" }); - expect(readDirectoryMarks("D:/added")).toEqual({build: "sources"}); - expect(readIndexingSettings("D:/added").includedDirectories).toEqual(["build"]); - markWorkspaceDirectory(roots, "D:/added/nested/vendor", { kind: "cancelExclusion" }); - expect(directoryMarkRoot("D:/ADDED/nested/vendor", roots)).toBe("D:/added/nested"); - expect(readIndexingSettings("D:/added/nested").includedDirectories).toEqual(["vendor"]); - expect(readIndexingSettings("C:/main")).toEqual({gitIgnoreEnabled: false, includedDirectories: ["keep"], previousRoles: {}}); - expect(readDirectoryMarks("C:/main")).toEqual({}); - expect(() => markWorkspaceDirectory(roots, "E:/outside", { kind: "exclude" })).toThrow(); -}); - -test("normal-folder role changes do not create Git include exceptions", () => { - const roots = ["C:/normal"]; - markWorkspaceDirectory(roots, "C:/normal/src", { kind: "setRole", role: "sources" }); - markWorkspaceDirectory(roots, "C:/normal/src", { kind: "setRole", role: "plain" }); - markWorkspaceDirectory(roots, "C:/normal/ordinary", { kind: "setRole", role: "plain" }); - expect(readIndexingSettings(roots[0]).includedDirectories).toEqual([]); - markWorkspaceDirectory(roots, "C:/normal/src", { kind: "exclude" }); - markWorkspaceDirectory(roots, "C:/normal/src", { kind: "cancelExclusion" }); - expect(readIndexingSettings(roots[0]).includedDirectories).toEqual(["src"]); -}); +test.each(reduceCases.map((item) => [item.id, item] as const))( + "reduce contract: %s", + async (_id, item) => { + const root = item.request.caseInsensitive ? "C:/contract" : "/contract"; + seed(root, item.request.rules); + const invoke = replayFixture(); + try { + await markWorkspaceDirectory([root], `${root}/${item.request.path}`, item.request.action); + expect(stored(root)).toEqual(emptyRules(item.response.rules)); + expect(invoke).toHaveBeenCalledTimes(1); + } finally { + invoke.mockRestore(); + } + }, +); +test.each(relocateCases.filter((item) => !item.request.target).map((item) => [item.id, item] as const))( + "relocate contract: %s", + async (_id, item) => { + const request = item.request; + const root = request.caseInsensitive ? "C:/contract" : "/contract"; + seed(root, request.rules); + const invoke = replayFixture(); + try { + const destination = request.destination === undefined ? undefined + : request.destination === "." ? root : `${root}/${request.destination}`; + await migrateDirectoryMarks(root, `${root}/${request.source}`, destination); + expect(stored(root)).toEqual(emptyRules(item.response.rules)); + } finally { + invoke.mockRestore(); + } + }, +); -test.each(["D:/added", "C:/main/nested"])("added-root marks and include exceptions follow rename and deletion: %s", (root) => { - const roots = ["C:/main", root]; - markWorkspaceDirectory(roots, `${root}/src`, { kind: "setRole", role: "sources" }); - markWorkspaceDirectory(roots, `${root}/src`, { kind: "exclude" }); - markWorkspaceDirectory(roots, `${root}/src/examples`, { kind: "cancelExclusion" }); - // Include exceptions cannot override a manually excluded ancestor. - expect(readIndexingSettings(root).includedDirectories).toEqual([]); - migrateWorkspaceDirectoryMarks(roots, `${root}/src`, `${root}/code`); - expect(readDirectoryMarks(root)).toEqual({code: "excluded"}); - expect(readIndexingSettings(root).previousRoles).toEqual({code: "sources"}); - markWorkspaceDirectory(roots, `${root}/code`, { kind: "cancelExclusion" }); - expect(readDirectoryMarks(root)).toEqual({code: "sources"}); - expect(readIndexingSettings(root).includedDirectories).toEqual(["code"]); - migrateWorkspaceDirectoryMarks(roots, `${root}/code`); - expect(readDirectoryMarks(root)).toEqual({}); - expect(readIndexingSettings(root).includedDirectories).toEqual([]); - expect(readIndexingSettings(root).previousRoles).toEqual({}); - expect(readDirectoryMarks("C:/main")).toEqual({}); +test("a move between workspace roots sends both roots' rules and stores both results", async () => { + const item = relocateCases.find((entry) => entry.id === "move-across-workspace-roots")!; + seed("C:/main", item.request.rules); + seed("D:/added", item.request.target!); + const invoke = replayFixture(); + try { + await migrateWorkspaceDirectoryMarks(["C:/main", "D:/added"], "C:/main/pkg", "D:/added/vendor/pkg"); + expect(stored("C:/main")).toEqual(emptyRules(item.response.rules)); + expect(stored("D:/added")).toEqual(emptyRules(item.response.target!)); + } finally { + invoke.mockRestore(); + } }); - -test.each(["D:/added", "C:/main/nested"])("moving between workspace roots preserves exclusions and exceptions: %s", (root) => { - const roots = ["C:/main", root]; - markWorkspaceDirectory(roots, `${root}/src`, { kind: "setRole", role: "sources" }); - markWorkspaceDirectory(roots, `${root}/src`, { kind: "exclude" }); - markWorkspaceDirectory(roots, `${root}/examples`, { kind: "cancelExclusion" }); - migrateWorkspaceDirectoryMarks(roots, `${root}/src`, "C:/main/code"); - migrateWorkspaceDirectoryMarks(roots, `${root}/examples`, "C:/main/demos"); - expect(readDirectoryMarks(root)).toEqual({}); - expect(readDirectoryMarks("C:/main")).toEqual({code: "excluded"}); - expect(readIndexingSettings("C:/main").previousRoles).toEqual({code: "sources"}); - expect(readIndexingSettings("C:/main").includedDirectories).toEqual(["demos"]); - expect(readIndexingSettings(root).includedDirectories).toEqual([]); - markWorkspaceDirectory(roots, "C:/main/code", { kind: "cancelExclusion" }); - expect(readDirectoryMarks("C:/main")).toEqual({code: "sources"}); +test("an unchanged answer writes nothing and a path outside every root is refused", async () => { + const blocked = reduceCases.find((entry) => entry.id === "manual-ancestor-exclusion-cannot-be-overridden")!; + seed("/contract", blocked.request.rules); + const invoke = replayFixture(); + const changes: string[] = []; + const listener = (event: Event) => changes.push(event.type); + window.addEventListener("lithe:directory-marks-changed", listener); + window.addEventListener("lithe:workspace-indexing-changed", listener); + try { + await markWorkspaceDirectory(["/contract"], "/contract/vendor/lib", { kind: "cancelExclusion" }); + expect(changes).toEqual([]); + await expect(markWorkspaceDirectory(["/contract"], "/elsewhere/dir", { kind: "exclude" })).rejects.toThrow(); + } finally { + invoke.mockRestore(); + window.removeEventListener("lithe:directory-marks-changed", listener); + window.removeEventListener("lithe:workspace-indexing-changed", listener); + } }); +test("a destination outside the root is sent as a deletion", async () => { + seed("C:/main", { marks: { src: "sources" } }); + const invoke = spyOn(native, "invoke").mockImplementation((async () => ({ rules: emptyRules({}) })) as typeof native.invoke); + try { + await migrateDirectoryMarks("C:/main", "C:/main/src", "D:/elsewhere/src"); + expect(invoke.mock.calls[0]?.[1]).toEqual({ + source: "src", destination: undefined, caseInsensitive: true, rules: emptyRules({ marks: { src: "sources" } }), + }); + expect(readDirectoryMarks("C:/main")).toEqual({}); + } finally { + invoke.mockRestore(); + } +}); -test("cross-root quota failure rolls back in reverse order without publishing partial state", () => { +test("cross-root quota failure rolls back in reverse order without publishing partial state", async () => { const roots = ["C:/main", "D:/added"]; - markWorkspaceDirectory(roots, "D:/added/long-directory-name", { kind: "setRole", role: "sources" }); - markWorkspaceDirectory(roots, "D:/added/long-directory-name", { kind: "exclude" }); + seed("D:/added", { marks: { "long-directory-name": "excluded" }, previousRoles: { "long-directory-name": "sources" } }); + const invoke = spyOn(native, "invoke").mockImplementation((async () => ({ + rules: emptyRules({}), + target: emptyRules({ marks: { code: "excluded" }, previousRoles: { code: "sources" } }), + })) as typeof native.invoke); const keys = roots.flatMap((root) => [ `lithe.workspace-directory-marks.${root.toLowerCase()}`, `lithe.workspace-indexing.${root.toLowerCase()}`, @@ -271,11 +239,12 @@ test("cross-root quota failure rolls back in reverse order without publishing pa get length() { return storage.length; }, setItem, }}); try { - expect(() => migrateWorkspaceDirectoryMarks(roots, "D:/added/long-directory-name", "C:/main/code")).toThrow(); + await expect(migrateWorkspaceDirectoryMarks(roots, "D:/added/long-directory-name", "C:/main/code")).rejects.toThrow(); expect(rejected).toBe(true); expect(keys.map((key) => storage.getItem(key))).toEqual(before); expect(published).toBe(0); } finally { + invoke.mockRestore(); if (storageDescriptor) Object.defineProperty(window, "localStorage", storageDescriptor); else Reflect.deleteProperty(window, "localStorage"); window.removeEventListener("lithe:directory-marks-changed", listener); @@ -322,22 +291,6 @@ test.each(["remote://host/project", "wsl://Ubuntu/project"])("unsupported roots } }); -for (const scenario of fixture.cases) { - test(`directory action contract: ${scenario.id}`, () => { - const root = "C:/role-contract"; - for (const step of scenario.steps) { - markWorkspaceDirectory([root], `${root}/archive`, step.action as DirectoryAction); - expect(String(readDirectoryMarks(root).archive ?? "plain")).toBe(step.role); - expect(readIndexingSettings(root).includedDirectories).toEqual(step.included ? ["archive"] : []); - } - const before = readIndexingSettings(root); - writeIndexingSettings(root, {...before, gitIgnoreEnabled: false}); - writeIndexingSettings(root, {...readIndexingSettings(root), gitIgnoreEnabled: true}); - expect(readIndexingSettings(root)).toEqual(before); - }); -} - - test.each([ ["zh-CN", "项目索引设置格式无效,原始设置已保留。"], ["en-US", "Project indexing settings are invalid. The saved data has been preserved."], @@ -350,6 +303,9 @@ test.each([ const key = indexingSettingsStorageKey(rootPath); window.localStorage.setItem(key, "{}"); const error = spyOn(toast, "error").mockReturnValue(0); + const invoke = spyOn(native, "invoke").mockImplementation((async () => ({ + changed: true, rules: emptyRules({ marks: { archive: "sources" } }), + })) as typeof native.invoke); let notifications = 0; const changed = () => notifications++; window.addEventListener("lithe:directory-marks-changed", changed); @@ -367,14 +323,16 @@ test.each([ const element = menu.contextMenuElement as import("react").ReactElement<{children: Array>}>; const mark = element.props.children[0]!.props.items.find((item) => item.id === "mark-directory")!.children!.find((item) => item.id === "mark-sources")!; mark.onClick(); + await act(async () => {}); expect(error.mock.calls[0]?.[1]?.description).toBe(message); expect(window.localStorage.getItem(key)).toBe("{}"); expect(notifications).toBe(0); window.localStorage.removeItem(key); mark.onClick(); + await act(async () => {}); expect(readDirectoryMarks(rootPath).archive).toBe("sources"); expect(error).toHaveBeenCalledTimes(1); expect(notifications).toBe(1); } finally { - await act(async () => renderer.unmount()); error.mockRestore(); + await act(async () => renderer.unmount()); error.mockRestore(); invoke.mockRestore(); window.removeEventListener("lithe:directory-marks-changed", changed); environment.IS_REACT_ACT_ENVIRONMENT = previous; } diff --git a/windows/tauri/src/features/file-explorer/stores/directory-marks.ts b/windows/tauri/src/features/file-explorer/stores/directory-marks.ts index 405c17bdee..0c286350d1 100644 --- a/windows/tauri/src/features/file-explorer/stores/directory-marks.ts +++ b/windows/tauri/src/features/file-explorer/stores/directory-marks.ts @@ -9,6 +9,7 @@ import { } from "./workspace-indexing"; import { directoryMarksStorageKey as key, readDirectoryMarks, type DirectoryAction, type DirectoryMark, type DirectoryMarks as Marks } from "./directory-marks-storage"; export { DIRECTORY_MARKS, readDirectoryMarks, type DirectoryAction, type DirectoryMark } from "./directory-marks-storage"; +import { invoke } from "@/platform/tauri-core"; import { useCallback, useEffect, useState } from "react"; import { closestWorkspaceRoot } from "@/features/workspace/types/workspace-root"; import { @@ -28,92 +29,76 @@ function writeMarks(root: string, marks: Marks) { window.localStorage.setItem(key(root), JSON.stringify({ version: 1, marks })); window.dispatchEvent(new window.CustomEvent(eventName, { detail: root })); } -export function migrateDirectoryMarks(root: string, source: string, destination?: string) { - if (!pathStartsWithRoot(source, root)) return; - const relative = getRelativePath(source, root).replace(/\\/g, "/"); - const replacement = - destination && pathStartsWithRoot(destination, root) - ? getRelativePath(destination, root).replace(/\\/g, "/") - : undefined; - const marks = readDirectoryMarks(root); - let changed = false; - const sourceIdentity = markPathIdentity(root, relative); - for (const path of Object.keys(marks)) { - const identity = markPathIdentity(root, path); - if (identity !== sourceIdentity && !identity.startsWith(`${sourceIdentity}/`)) continue; - const mark = marks[path]; - delete marks[path]; - changed = true; - if (replacement) marks[replacement + path.slice(relative.length)] = mark; - } +/** The rule state of one workspace root, exactly as Rust Core's directory rules model it. */ +interface DirectoryRules { + marks: Marks; + includedDirectories: string[]; + previousRoles: Record; +} + +const readRules = (root: string): DirectoryRules => { const settings = readIndexingSettings(root); - const migrate = (path: string): string | undefined => { - const identity = markPathIdentity(root, path); - if (identity !== sourceIdentity && !identity.startsWith(`${sourceIdentity}/`)) return path; - return replacement ? replacement + path.slice(relative.length) : undefined; + return { + marks: readDirectoryMarks(root), + includedDirectories: settings.includedDirectories, + previousRoles: settings.previousRoles, }; - settings.includedDirectories = settings.includedDirectories - .map(migrate) - .filter((path): path is string => path !== undefined); - settings.previousRoles = Object.fromEntries( - Object.entries(settings.previousRoles).flatMap(([path, role]) => { - const moved = migrate(path); - return moved === undefined ? [] : [[moved, role]]; - }), - ); - writeIndexingSettings(root, settings); - if (changed) writeMarks(root, marks); +}; + +/** Stores Core's rule state in the two preference entries it is split across. */ +const storeRules = (root: string, rules: DirectoryRules, settings = readIndexingSettings(root)): [string, string][] => [ + [key(root), JSON.stringify({ version: 1, marks: rules.marks })], + [ + indexingSettingsStorageKey(root), + JSON.stringify({ ...settings, includedDirectories: rules.includedDirectories, previousRoles: rules.previousRoles }), + ], +]; + +const relativeTo = (root: string, path: string): string => + getRelativePath(path, root).replace(/\\/g, "/") || "."; + +/** + * Moves or drops the rules of a moved, renamed or deleted directory inside one root. + * Core owns the migration (`workspace.relocateDirectoryRules`); this only loads and + * persists the root's preferences. + */ +export async function migrateDirectoryMarks(root: string, source: string, destination?: string) { + if (!pathStartsWithRoot(source, root)) return; + const rules = readRules(root); + const { rules: next } = await invoke<{ rules: DirectoryRules }>("workspace.relocateDirectoryRules", { + source: relativeTo(root, source), + destination: destination && pathStartsWithRoot(destination, root) ? relativeTo(root, destination) : undefined, + caseInsensitive: isCaseInsensitivePath(root), + rules, + }); + if (JSON.stringify(next) === JSON.stringify(rules)) return; + const settings = readIndexingSettings(root); + writeIndexingSettings(root, { ...settings, includedDirectories: next.includedDirectories, previousRoles: next.previousRoles }); + if (JSON.stringify(next.marks) !== JSON.stringify(rules.marks)) writeMarks(root, next.marks); } /** The closest workspace owns preferences, including nested added roots. */ export function directoryMarkRoot(path: string, roots: readonly string[]): string | undefined { return closestWorkspaceRoot(path, roots); } -export function migrateWorkspaceDirectoryMarks(roots: readonly string[], source: string, destination?: string) { +export async function migrateWorkspaceDirectoryMarks(roots: readonly string[], source: string, destination?: string) { const root = directoryMarkRoot(source, roots); if (!root) return; const targetRoot = destination ? directoryMarkRoot(destination, roots) : undefined; if (!destination || !targetRoot || getPathIdentity(root) === getPathIdentity(targetRoot)) { - migrateDirectoryMarks(root, source, destination); + await migrateDirectoryMarks(root, source, destination); return; } - const sourceRelative = getRelativePath(source, root).replace(/\\/g, "/") || "."; - const targetRelative = getRelativePath(destination, targetRoot).replace(/\\/g, "/") || "."; - const movePath = (path: string): string | undefined => { - const identity = markPathIdentity(root, path); - const sourceIdentity = markPathIdentity(root, sourceRelative); - if (sourceIdentity !== "." && identity !== sourceIdentity && !identity.startsWith(sourceIdentity + "/")) return; - const suffix = sourceRelative === "." ? (path === "." ? "" : "/" + path) : path.slice(sourceRelative.length); - return targetRelative === "." ? suffix.replace(/^\//, "") || "." : targetRelative + suffix; - }; - const sourceMarks = readDirectoryMarks(root); - const targetMarks = readDirectoryMarks(targetRoot); - const sourceSettings = readIndexingSettings(root); - const targetSettings = readIndexingSettings(targetRoot); - for (const path of Object.keys(sourceMarks)) { - const moved = movePath(path); - if (moved === undefined) continue; - targetMarks[moved] = sourceMarks[path]; - delete sourceMarks[path]; - } - sourceSettings.includedDirectories = sourceSettings.includedDirectories.filter((path) => { - const moved = movePath(path); - if (moved === undefined) return true; - if (!targetSettings.includedDirectories.includes(moved)) targetSettings.includedDirectories.push(moved); - return false; + const sourceRules = readRules(root); + const targetRules = readRules(targetRoot); + const response = await invoke<{ rules: DirectoryRules; target: DirectoryRules }>("workspace.relocateDirectoryRules", { + source: relativeTo(root, source), + destination: relativeTo(targetRoot, destination), + caseInsensitive: isCaseInsensitivePath(root), + rules: sourceRules, + target: targetRules, }); - for (const path of Object.keys(sourceSettings.previousRoles)) { - const moved = movePath(path); - if (moved === undefined) continue; - targetSettings.previousRoles[moved] = sourceSettings.previousRoles[path]; - delete sourceSettings.previousRoles[path]; - } - const writes: [string, string][] = [ - [key(root), JSON.stringify({version: 1, marks: sourceMarks})], - [indexingSettingsStorageKey(root), JSON.stringify(sourceSettings)], - [key(targetRoot), JSON.stringify({version: 1, marks: targetMarks})], - [indexingSettingsStorageKey(targetRoot), JSON.stringify(targetSettings)], - ]; + const writes = [...storeRules(root, response.rules), ...storeRules(targetRoot, response.target)]; const previous = writes.map(([key]) => [key, window.localStorage.getItem(key)] as const); let completed = 0; try { @@ -135,55 +120,29 @@ export function migrateWorkspaceDirectoryMarks(roots: readonly string[], source: } } -export function markWorkspaceDirectory(roots: readonly string[], path: string, action: DirectoryAction) { +/** + * Applies a directory role, exclusion or cancellation. Rust Core reduces the stored rules + * (`workspace.reduceDirectoryAction`); this loads them, persists the result and notifies. + */ +export async function markWorkspaceDirectory(roots: readonly string[], path: string, action: DirectoryAction) { const root = directoryMarkRoot(path, roots); if (!root) throw new Error("Directory is outside the workspace"); - const marks = readDirectoryMarks(root); - const relative = getRelativePath(path, root).replace(/\\/g, "/") || "."; - const relativeIdentity = markPathIdentity(root, relative); - const samePath = (path: string) => markPathIdentity(root, path) === relativeIdentity; - const markKeys = Object.keys(marks).filter(samePath); + const rules = readRules(root); + const reduced = await invoke<{ changed: boolean; rules: DirectoryRules }>("workspace.reduceDirectoryAction", { + path: relativeTo(root, path), + action, + caseInsensitive: isCaseInsensitivePath(root), + rules, + }); + if (!reduced.changed) return; const settings = readIndexingSettings(root); - const currentKey = markKeys.find((path) => path === relative) ?? markKeys[0]; - const current = currentKey === undefined ? undefined : marks[currentKey]; - if (action.kind === "cancelExclusion") { - if ( - Object.entries(marks).some( - ([path, value]) => - value === "excluded" && - !samePath(path) && - (path === "." || relativeIdentity.startsWith(markPathIdentity(root, path) + "/")), - ) - ) - return; - const previousKeys = Object.keys(settings.previousRoles).filter(samePath); - const previousKey = previousKeys.find((path) => path === relative) ?? previousKeys[0]; - const previous = previousKey === undefined ? undefined : settings.previousRoles[previousKey]; - for (const path of markKeys) delete marks[path]; - for (const path of previousKeys) delete settings.previousRoles[path]; - const restored = current === "excluded" ? previous : current; - if (restored) marks[relative] = restored as DirectoryMark; - if (!settings.includedDirectories.some(samePath)) - settings.includedDirectories.push(relative); - } else { - const mark = action.kind === "exclude" ? "excluded" : action.role; - const previousKey = markKeys.find((path) => path === relative) ?? markKeys[0]; - const previous = previousKey === undefined ? undefined : marks[previousKey]; - if (mark === "excluded" && previous && previous !== "excluded") - settings.previousRoles[relative] = previous; - for (const path of markKeys) delete marks[path]; - marks[relative] = mark; - if (action.kind === "exclude") settings.includedDirectories = settings.includedDirectories.filter( - (path) => !samePath(path), - ); - } let oldSettings: string | null; try { oldSettings = window.localStorage.getItem(indexingSettingsStorageKey(root)); } catch (cause) { throw new IndexingSettingsError("readFailed", cause); } try { // Publish only after both preference writes succeed. - window.localStorage.setItem(indexingSettingsStorageKey(root), JSON.stringify(settings)); - writeMarks(root, marks); + window.localStorage.setItem(indexingSettingsStorageKey(root), storeRules(root, reduced.rules, settings)[1][1]); + writeMarks(root, reduced.rules.marks); window.dispatchEvent(new window.CustomEvent(indexingEvent, { detail: root })); } catch (error) { if (oldSettings === null) window.localStorage.removeItem(indexingSettingsStorageKey(root)); @@ -206,9 +165,8 @@ export function useDirectoryMarks(roots: readonly string[]) { refresh(); return subscribeToIndexingChanges(roots, refresh); }, [rootsKey]); - const markDirectory = useCallback((path: string, action: DirectoryAction) => { - markWorkspaceDirectory(roots, path, action); - }, [rootsKey]); + const markDirectory = useCallback((path: string, action: DirectoryAction) => + markWorkspaceDirectory(roots, path, action), [rootsKey]); const markForPath = (path: string): DirectoryMark | undefined => { const root = directoryMarkRoot(path, roots); if (!root || snapshot.key !== rootsKey) return undefined; From 77a576ab1de5de1603c0c465cc1b9c1e1c8e6680 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 05:18:04 -0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(core):=20#1662=20WS-07=20=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E4=B8=8E=E7=B2=98=E8=B4=B4=E9=87=8D=E5=90=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=BB=9F=E4=B8=80=E4=B8=BA=20Core=20=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 workspace.planDuplicate 与 workspace.planPaste,统一为 ` copy` / ` copy N`;平台只提供已存在名称并以不覆盖方式创建,冲突时重新规划。 Co-Authored-By: Claude Opus 5.5 --- .../Core/Rust/RustWorkspaceOperations.swift | 16 ++ .../Remote/RemoteWorkspaceRouting.swift | 16 ++ .../Workspace/WorkspaceFeatureContracts.swift | 49 +++++ .../Application/WorkspaceFeatureModel.swift | 196 ++++++++--------- .../LazyProjectTreeInventoryTests.swift | 2 +- .../LitheTests/LitheCoreLogicTests.swift | 8 +- .../ProjectTreeNameOrderTests.swift | 23 ++ .../WorkspaceRulesCoreContractTests.swift | 115 ++++++++++ .../lithe-core/src/project/file_operations.rs | 170 ++++++++++++++ rust/lithe-core/src/project/mod.rs | 2 + rust/lithe-core/src/protocol/command.rs | 15 ++ rust/lithe-core/src/runtime/dispatcher.rs | 23 ++ rust/lithe-core/src/tests/file_operations.rs | 70 ++++++ rust/lithe-core/src/tests/mod.rs | 3 + shared/contracts/rust-core-api.md | 33 +++ .../workspace/file-copy-names-v1.json | 25 +++ .../features/workspace-files.json | 16 +- windows/tauri/src-tauri/src/host.rs | 207 +++++++++++++----- windows/tauri/src-tauri/src/main.rs | 1 + .../components/file-explorer-tree.tsx | 1 + .../lib/paste-into-explorer-directory.ts | 21 +- .../file-system/stores/file-system.store.ts | 104 ++++----- .../file-system/utils/copy-names.test.ts | 41 ++++ .../features/file-system/utils/copy-names.ts | 31 +++ 24 files changed, 960 insertions(+), 228 deletions(-) create mode 100644 macos/Tests/LitheTests/WorkspaceRulesCoreContractTests.swift create mode 100644 rust/lithe-core/src/project/file_operations.rs create mode 100644 rust/lithe-core/src/tests/file_operations.rs create mode 100644 shared/fixtures/workspace/file-copy-names-v1.json create mode 100644 windows/tauri/src/features/file-system/utils/copy-names.test.ts create mode 100644 windows/tauri/src/features/file-system/utils/copy-names.ts diff --git a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift index 850fe593de..eac534a642 100644 --- a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift @@ -70,6 +70,22 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { })?.map { rows[$0] } } + func copyName(for name: String, isDirectory: Bool, existing: [String], duplicate: Bool) -> String? { + core.copyName(for: name, isDirectory: isDirectory, existing: existing, duplicate: duplicate) + } + + func reduceDirectoryAction( + _ action: WorkspaceDirectoryAction, at path: String, rules: WorkspaceDirectoryRules + ) -> WorkspaceDirectoryReduction? { + core.reduceDirectoryAction(action, at: path, rules: rules) + } + + func relocateDirectoryRules( + _ rules: WorkspaceDirectoryRules, moving source: String, to destination: String? + ) -> WorkspaceDirectoryRules? { + core.relocateDirectoryRules(rules, moving: source, to: destination) + } + func rootRows(at rootURL: URL, visibilityRules: FileVisibilityRules) -> [FileNode]? { guard let entries = listDirectories([rootURL], at: rootURL, visibilityRules: visibilityRules)?[rootURL.standardizedFileURL.path] else { return nil diff --git a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift index f90e6ee9e4..018407437d 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift @@ -203,6 +203,22 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { local.orderedTreeSiblings(rows) } + func copyName(for name: String, isDirectory: Bool, existing: [String], duplicate: Bool) -> String? { + local.copyName(for: name, isDirectory: isDirectory, existing: existing, duplicate: duplicate) + } + + func reduceDirectoryAction( + _ action: WorkspaceDirectoryAction, at path: String, rules: WorkspaceDirectoryRules + ) -> WorkspaceDirectoryReduction? { + local.reduceDirectoryAction(action, at: path, rules: rules) + } + + func relocateDirectoryRules( + _ rules: WorkspaceDirectoryRules, moving source: String, to destination: String? + ) -> WorkspaceDirectoryRules? { + local.relocateDirectoryRules(rules, moving: source, to: destination) + } + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { local.warmSearchIndex(at: rootURL, visibilityRules: visibilityRules) } diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift index c3c81f4523..a4e87ff123 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift @@ -96,6 +96,20 @@ package protocol WorkspaceOperations: Sendable { /// first, then `workspace.orderEntries` name order); nil when Core cannot /// order them. Swift never compares file names itself. func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? + /// Core's name for a copy (`workspace.planPaste`, or `workspace.planDuplicate` when + /// `duplicate`) given the sibling names already known to exist; nil when Core cannot + /// plan. Swift never builds copy names itself. + func copyName(for name: String, isDirectory: Bool, existing: [String], duplicate: Bool) -> String? + /// Core's result of applying `action` to `path` (workspace-relative) in `rules` + /// (`workspace.reduceDirectoryAction`); nil when Core rejects the request. + func reduceDirectoryAction( + _ action: WorkspaceDirectoryAction, at path: String, rules: WorkspaceDirectoryRules + ) -> WorkspaceDirectoryReduction? + /// Core's migration of `rules` after `source` moved to `destination`, or was deleted + /// when `destination` is nil (`workspace.relocateDirectoryRules`); nil when rejected. + func relocateDirectoryRules( + _ rules: WorkspaceDirectoryRules, moving source: String, to destination: String? + ) -> WorkspaceDirectoryRules? } extension WorkspaceOperations { @@ -168,6 +182,14 @@ package extension WorkspaceOperations { /// Operations without Core (test doubles) cannot order rows, so an inserted /// file reloads its directory listing instead. func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? { nil } + /// Operations without Core (test doubles) cannot plan, so the file operation is refused. + func copyName(for name: String, isDirectory: Bool, existing: [String], duplicate: Bool) -> String? { nil } + func reduceDirectoryAction( + _ action: WorkspaceDirectoryAction, at path: String, rules: WorkspaceDirectoryRules + ) -> WorkspaceDirectoryReduction? { nil } + func relocateDirectoryRules( + _ rules: WorkspaceDirectoryRules, moving source: String, to destination: String? + ) -> WorkspaceDirectoryRules? { nil } } package protocol DirectoryWatcherFactory { @@ -249,6 +271,33 @@ public enum LocalHistoryReason: String, Codable, Sendable { } } +/// An editing event that may record local-history snapshots. Rust Core decides which +/// snapshots each event records (`history.planEvent`), so every platform records at the +/// same moments. +public enum LocalHistoryEvent: String, Sendable { + case save, autoSave, restore, manual, unsavedDiscard, externalChange + case beforeRename, beforeBatchReplace +} + +/// One snapshot Core plans for an event. +public struct LocalHistorySnapshotPlan: Equatable, Sendable { + /// Whose text the snapshot holds. + public enum Source: String, Sendable { + /// The file's text before the event changed it. + case beforeChange + /// The file's text after the event completed. + case afterChange + /// Unsaved editor text the event discards. + case editorBuffer + } + public let source: Source + public let reason: LocalHistoryReason + public init(source: Source, reason: LocalHistoryReason) { + self.source = source + self.reason = reason + } +} + package extension WorkspaceDirectoryMarkStoring { func loadIndexingSettings(for workspaceURL: URL) throws -> WorkspaceIndexingSettings { WorkspaceIndexingSettings() } func saveIndexingSettings(_ settings: WorkspaceIndexingSettings, for workspaceURL: URL) throws {} diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 2cf8faecaa..d731fdaca6 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -100,7 +100,7 @@ package final class WorkspaceFeatureModel: ObservableObject { private func notifyOperationFailure(_ message: String) { if let notifyFailure { notifyFailure(message) } else { notify?(message) } } - private var recordHistory: (@MainActor (URL, LocalHistoryReason) async -> Void)? + private var recordHistory: (@MainActor (URL, LocalHistoryEvent) async -> Void)? private var relocateHistory: (@MainActor (URL, URL) async -> Void)? /// Moves an item through the version control that tracks it, so the /// move is recorded; false leaves the move to the file system. @@ -149,7 +149,7 @@ package final class WorkspaceFeatureModel: ObservableObject { restoreSession: @escaping @MainActor (WorkspaceSession, [URL]) async -> Void, openFile: @escaping @MainActor (URL) -> Void, notify: @escaping @MainActor (String) -> Void, - recordHistory: @escaping @MainActor (URL, LocalHistoryReason) async -> Void, + recordHistory: @escaping @MainActor (URL, LocalHistoryEvent) async -> Void, relocateHistory: @escaping @MainActor (URL, URL) async -> Void, moveTrackedItem: @escaping @MainActor (URL, URL) async -> Bool = { _, _ in false }, relocateOpenDocuments: @escaping @MainActor (URL, URL) -> Void, @@ -659,24 +659,20 @@ package final class WorkspaceFeatureModel: ObservableObject { let relativePath = Self.relativePath(for: url, in: workspaceURL) else { return } let workspaceGeneration = self.workspaceGeneration let previousMarks = directoryMarks - var updatedMarks = directoryMarks let previousSettings = indexingSettings - switch action { - case .cancelExclusion: - guard !directoryMarks.contains(where: { $0.value == .excluded && $0.key != relativePath && (relativePath.hasPrefix($0.key + "/") || $0.key == ".") }) else { return } - let remembered = indexingSettings.previousRoles.removeValue(forKey: relativePath) - if updatedMarks[relativePath] == .excluded { - updatedMarks[relativePath] = remembered ?? .plain - } - if !indexingSettings.includedDirectories.contains(relativePath) { indexingSettings.includedDirectories.append(relativePath) } - case .exclude: - if let previous = updatedMarks[relativePath], previous != .excluded { indexingSettings.previousRoles[relativePath] = previous } - updatedMarks[relativePath] = .excluded - indexingSettings.includedDirectories.removeAll { $0 == relativePath } - case .setRole(let role): - guard role != .excluded else { return } - updatedMarks[relativePath] = role - } + if case .setRole(.excluded) = action { return } + // Core applies the action: it remembers the role an exclusion replaces, restores + // it when the exclusion is canceled, and refuses to override an excluded ancestor. + guard let reduction = operations.reduceDirectoryAction( + action, at: relativePath, + rules: WorkspaceDirectoryRules( + marks: directoryMarks, includedDirectories: indexingSettings.includedDirectories, + previousRoles: indexingSettings.previousRoles + ) + ), reduction.changed else { return } + let updatedMarks = reduction.rules.marks + indexingSettings.includedDirectories = reduction.rules.includedDirectories + indexingSettings.previousRoles = reduction.rules.previousRoles guard updatedMarks != previousMarks || previousSettings != indexingSettings else { return } let previousMarkRevision = directoryMarkRevision @@ -903,8 +899,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } else { marksToMigrate = operationDirectoryMarks } - migrateIndexingSettings(moving: request.targetURL, to: destination, in: operationWorkspaceURL) - let migratedMarks = Self.directoryMarks( + let migratedMarks = migrateDirectoryRules( marksToMigrate, moving: request.targetURL, to: destination, @@ -1000,9 +995,8 @@ package final class WorkspaceFeatureModel: ObservableObject { // Relocate open buffers immediately, before any further suspension. if isCurrent { relocateOpenDocuments?(source, destination) } let marks = isCurrent ? directoryMarks : previousMarks - migrateIndexingSettings(moving: source, to: destination, in: workspace) await persistDirectoryMarksAfterFileOperation( - Self.directoryMarks(marks, moving: source, to: destination, in: workspace), + migrateDirectoryRules(marks, moving: source, to: destination, in: workspace), previousMarks: marks, workspaceURL: workspace, workspaceGeneration: generation ) guard workspaceGeneration == generation, workspaceURL == workspace else { return } @@ -1060,9 +1054,11 @@ package final class WorkspaceFeatureModel: ObservableObject { let operationWorkspaceURL = workspaceURL else { return false } let generation = workspaceGeneration guard let sources = validatedCopySources(urls, in: directory, workspace: operationWorkspaceURL) else { return false } - let fileOperations = self.fileOperations, logSink = self.logSink + let fileOperations = self.fileOperations, logSink = self.logSink, operations = self.operations let result = await UserActionContext.detached(priority: .userInitiated) { - Self.copyProjectItems(sources, in: directory, fileOperations: fileOperations, logSink: logSink) + Self.copyProjectItems( + sources, in: directory, fileOperations: fileOperations, operations: operations, logSink: logSink + ) }.value guard workspaceURL == operationWorkspaceURL, workspaceGeneration == generation else { return false } if let error = result.1 { notifyOperationFailure(error) } else { notify?("Pasted files") } @@ -1094,21 +1090,16 @@ package final class WorkspaceFeatureModel: ObservableObject { } private nonisolated static func copyProjectItems( - _ sources: [URL], in directory: URL, fileOperations: any WorkspaceFileOperations, logSink: (any LogSink)? + _ sources: [URL], in directory: URL, fileOperations: any WorkspaceFileOperations, + operations: any WorkspaceOperations, logSink: (any LogSink)? ) -> (Int, String?) { var copied = 0 do { for source in sources { - var destination = directory.appendingPathComponent(source.lastPathComponent) - var index = 1 - while fileOperations.fileExists(at: destination) { - let ext = source.pathExtension - let base = ext.isEmpty ? source.lastPathComponent : source.deletingPathExtension().lastPathComponent - let suffix = index == 1 ? " copy" : " copy \(index)" - destination = directory.appendingPathComponent(base + suffix + (ext.isEmpty ? "" : "." + ext)) - index += 1 - } - try fileOperations.copyItem(at: source, to: destination) + _ = try copyWithoutReplacing( + source, in: directory, duplicate: false, + fileOperations: fileOperations, operations: operations + ) copied += 1 } return (copied, nil) @@ -1118,16 +1109,50 @@ package final class WorkspaceFeatureModel: ObservableObject { } } + /// Most attempts for one destination; each failed attempt adds a name that exists. + private nonisolated static let maxCopyNameAttempts = 512 + + /// Copies `source` into `directory` under a name Core plans from the names that exist, + /// never replacing an existing item. A name taken between the existence probe and the + /// copy makes the copy fail, which only adds the name to the facts and plans again. + private nonisolated static func copyWithoutReplacing( + _ source: URL, in directory: URL, duplicate: Bool, + fileOperations: any WorkspaceFileOperations, operations: any WorkspaceOperations + ) throws -> URL { + let name = source.lastPathComponent + let isDirectory = fileOperations.isDirectory(at: source) + var existing: [String] = [] + for _ in 0.. String? in do { - try fileOperations.copyItem(at: sourceURL, to: destination) + _ = try Self.copyWithoutReplacing( + sourceURL, in: sourceURL.deletingLastPathComponent(), duplicate: true, + fileOperations: fileOperations, operations: operations + ) return nil } catch { logSink?.emit(AppWorkspaceFileOperationFailed(error: error, stage: "file-operation")) @@ -1270,10 +1295,10 @@ package final class WorkspaceFeatureModel: ObservableObject { } else { marksToRemoveFrom = operationDirectoryMarks } - migrateIndexingSettings(moving: request.url, to: nil, in: operationWorkspaceURL) - let remainingMarks = Self.directoryMarks( + let remainingMarks = migrateDirectoryRules( marksToRemoveFrom, - removing: request.url, + moving: request.url, + to: nil, in: operationWorkspaceURL ) await persistDirectoryMarksAfterFileOperation( @@ -1306,22 +1331,36 @@ package final class WorkspaceFeatureModel: ObservableObject { }.value } - private func migrateIndexingSettings(moving source: URL, to destination: URL?, in root: URL) { + /// Carries the directory rules of a moved (or, without a destination, deleted) directory + /// to their new keys. Core owns the migration; this loads and saves the root's + /// indexing settings and returns the migrated marks for the caller to persist. + private func migrateDirectoryRules( + _ marks: [String: WorkspaceDirectoryMark], + moving source: URL, + to destination: URL?, + in root: URL + ) -> [String: WorkspaceDirectoryMark] { + guard let sourcePath = Self.relativePath(for: source, in: root) else { return marks } + let destinationPath = destination.flatMap { Self.relativePath(for: $0, in: root) } do { - var settings = try directoryMarkStore.loadIndexingSettings(for: root) - let includes = Dictionary(settings.includedDirectories.map { ($0, WorkspaceDirectoryMark.plain) }, uniquingKeysWith: { first, _ in first }) - if let destination { - settings.includedDirectories = Self.directoryMarks(includes, moving: source, to: destination, in: root).keys.sorted() - settings.previousRoles = Self.directoryMarks(settings.previousRoles, moving: source, to: destination, in: root) - } else { - settings.includedDirectories = Self.directoryMarks(includes, removing: source, in: root).keys.sorted() - settings.previousRoles = Self.directoryMarks(settings.previousRoles, removing: source, in: root) + let settings = try directoryMarkStore.loadIndexingSettings(for: root) + let rules = WorkspaceDirectoryRules( + marks: marks, includedDirectories: settings.includedDirectories, + previousRoles: settings.previousRoles + ) + guard let migrated = operations.relocateDirectoryRules(rules, moving: sourcePath, to: destinationPath) else { + throw WorkspaceIndexingFailure.writeFailed } - try directoryMarkStore.saveIndexingSettings(settings, for: root) - if workspaceURL == root { indexingSettingsRevision &+= 1; indexingSettings = settings } + var updated = settings + updated.includedDirectories = migrated.includedDirectories + updated.previousRoles = migrated.previousRoles + try directoryMarkStore.saveIndexingSettings(updated, for: root) + if workspaceURL == root { indexingSettingsRevision &+= 1; indexingSettings = updated } + return migrated.marks } catch { recordIndexingFailure(error, fallback: .writeFailed) notifyOperationFailure(indexingError ?? "") + return marks } } @@ -1349,39 +1388,6 @@ package final class WorkspaceFeatureModel: ObservableObject { notifyOperationFailure("Could not update directory marks after the file operation: \(saveError)") } - private static func directoryMarks( - _ marks: [String: WorkspaceDirectoryMark], - moving sourceURL: URL, - to destinationURL: URL, - in workspaceURL: URL - ) -> [String: WorkspaceDirectoryMark] { - guard let sourcePath = relativePath(for: sourceURL, in: workspaceURL), - let destinationPath = relativePath(for: destinationURL, in: workspaceURL) else { - return marks - } - let sourcePrefix = sourcePath + "/" - var updatedMarks = marks.filter { key, _ in - key != sourcePath && !key.hasPrefix(sourcePrefix) - } - for (key, mark) in marks where key == sourcePath || key.hasPrefix(sourcePrefix) { - let suffix = String(key.dropFirst(sourcePath.count)) - updatedMarks[destinationPath + suffix] = mark - } - return updatedMarks - } - - private static func directoryMarks( - _ marks: [String: WorkspaceDirectoryMark], - removing targetURL: URL, - in workspaceURL: URL - ) -> [String: WorkspaceDirectoryMark] { - guard let targetPath = relativePath(for: targetURL, in: workspaceURL) else { return marks } - let targetPrefix = targetPath + "/" - return marks.filter { key, _ in - key != targetPath && !key.hasPrefix(targetPrefix) - } - } - private func startWatching( _ configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules @@ -2093,24 +2099,6 @@ package final class WorkspaceFeatureModel: ObservableObject { ) } - private func availableDuplicateURL(for sourceURL: URL) -> URL { - let parent = sourceURL.deletingLastPathComponent() - let fileExtension = sourceURL.pathExtension - let baseName = fileExtension.isEmpty - ? sourceURL.lastPathComponent - : sourceURL.deletingPathExtension().lastPathComponent - var index = 1 - while true { - let suffix = index == 1 ? " copy" : " copy \(index)" - let name = fileExtension.isEmpty - ? "\(baseName)\(suffix)" - : "\(baseName)\(suffix).\(fileExtension)" - let candidate = parent.appendingPathComponent(name) - if !fileOperations.fileExists(at: candidate) { return candidate } - index += 1 - } - } - private func isValidProjectItemName(_ name: String) -> Bool { !name.isEmpty && name != "." && name != ".." && !name.contains("/") && !name.contains(":") } diff --git a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift index 63051f365e..0b926142bc 100644 --- a/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift +++ b/macos/Tests/LitheTests/LazyProjectTreeInventoryTests.swift @@ -835,7 +835,7 @@ private final class InventoryFixture { /// Both protocols are synchronous; only WorkspaceFeatureModel's production /// listing worker enters the bounded blocking gate, never the test's main actor. -private final class InventoryDisk: WorkspaceOperations, WorkspaceFileOperations, @unchecked Sendable { +private final class InventoryDisk: WorkspaceOperations, CoreBackedWorkspaceRules, WorkspaceFileOperations, @unchecked Sendable { private let lock = NSLock() private var files: [URL] private var rows: [String: [FileNode]] diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 409587c531..600664eb5d 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -6254,7 +6254,7 @@ private func makeWorkspaceObservationUnitModel( processExternalChanges: @escaping @MainActor ([URL]) -> Bool = { _ in false }, notifyWorkspaceFileChanges: @escaping @MainActor ([WorkspaceFileChange]) -> Void = { _ in }, reloadProjectServices: @escaping @MainActor () async -> Void = {}, - recordHistory: @escaping @MainActor (URL, LocalHistoryReason) async -> Void = { _, _ in }, + recordHistory: @escaping @MainActor (URL, LocalHistoryEvent) async -> Void = { _, _ in }, relocateHistory: @escaping @MainActor (URL, URL) async -> Void = { _, _ in }, directoryMarkStore: any WorkspaceDirectoryMarkStoring = EmptyWorkspaceDirectoryMarkStore(), observationDelay: (@Sendable (Duration) async throws -> Void)? = nil, @@ -6722,7 +6722,7 @@ struct EditorSessionCoordinatorTests { } } -private struct EmptyWorkspaceOperations: WorkspaceOperations { +private struct EmptyWorkspaceOperations: WorkspaceOperations, CoreBackedWorkspaceRules { let readFileValue: String? init(readFileValue: String? = nil) { @@ -6887,7 +6887,7 @@ private final class BlockingBinaryWorkspaceOperations: WorkspaceOperations, @unc func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } } -private final class SequencedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { +private final class SequencedWorkspaceOperations: WorkspaceOperations, CoreBackedWorkspaceRules, @unchecked Sendable { func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? { coreTreeOrder(rows) } private let lock = NSLock() private var snapshotAvailability: [Bool] @@ -7517,7 +7517,7 @@ private struct PreviewExternalChangeLifecycleDecider: DocumentLifecycleDeciding /// Records how many copies and Trash moves had finished each time the tree /// was rescanned, to show when a batch refreshes the project tree. -private final class BatchProgressScanOperations: WorkspaceOperations, @unchecked Sendable { +private final class BatchProgressScanOperations: WorkspaceOperations, CoreBackedWorkspaceRules, @unchecked Sendable { private let files: RecordingBatchProjectFileOperations private let lock = NSLock() private var progress: [[Int]] = [] diff --git a/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift b/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift index 7ee557ebc7..27c12eb597 100644 --- a/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift +++ b/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift @@ -84,3 +84,26 @@ struct ProjectTreeNameOrderTests { func coreTreeOrder(_ rows: [FileNode]) -> [FileNode]? { RustWorkspaceOperations(core: RustCoreBridge()).orderedTreeSiblings(rows) } + +/// Test doubles that conform get the same Core-backed copy-name and directory-rule +/// operations production uses, so their tests exercise Core's rules instead of a stub. +protocol CoreBackedWorkspaceRules: WorkspaceOperations {} + +extension CoreBackedWorkspaceRules { + func copyName(for name: String, isDirectory: Bool, existing: [String], duplicate: Bool) -> String? { + RustWorkspaceOperations(core: RustCoreBridge()) + .copyName(for: name, isDirectory: isDirectory, existing: existing, duplicate: duplicate) + } + + func reduceDirectoryAction( + _ action: WorkspaceDirectoryAction, at path: String, rules: WorkspaceDirectoryRules + ) -> WorkspaceDirectoryReduction? { + RustWorkspaceOperations(core: RustCoreBridge()).reduceDirectoryAction(action, at: path, rules: rules) + } + + func relocateDirectoryRules( + _ rules: WorkspaceDirectoryRules, moving source: String, to destination: String? + ) -> WorkspaceDirectoryRules? { + RustWorkspaceOperations(core: RustCoreBridge()).relocateDirectoryRules(rules, moving: source, to: destination) + } +} diff --git a/macos/Tests/LitheTests/WorkspaceRulesCoreContractTests.swift b/macos/Tests/LitheTests/WorkspaceRulesCoreContractTests.swift new file mode 100644 index 0000000000..f8dfaec121 --- /dev/null +++ b/macos/Tests/LitheTests/WorkspaceRulesCoreContractTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +/// Replays the shared Rust Core fixtures through the macOS bridge, so Swift sends the +/// requests Core expects and decodes its answers without a second copy of the rules. +@Suite("Workspace rules Core contract") +struct WorkspaceRulesCoreContractTests { + private static func fixture(_ path: String) throws -> [String: Any] { + let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: repository.appendingPathComponent("shared/fixtures/" + path)) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private let operations = RustWorkspaceOperations(core: RustCoreBridge()) + + @Test + func copyNamesMatchTheSharedFixture() throws { + let fixture = try Self.fixture("workspace/file-copy-names-v1.json") + for item in try #require(fixture["duplicate"] as? [[String: Any]]) { + let request = try #require(item["request"] as? [String: Any]) + guard request["caseInsensitive"] == nil else { continue } // macOS probes the volume instead + #expect(operations.copyName( + for: request["name"] as! String, isDirectory: request["isDirectory"] as! Bool, + existing: request["existing"] as? [String] ?? [], duplicate: true + ) == item["name"] as? String, "\(item["id"] ?? "")") + } + for item in try #require(fixture["paste"] as? [[String: Any]]) { + let request = try #require(item["request"] as? [String: Any]) + let items = try #require(request["items"] as? [[String: Any]]) + guard items.count == 1, request["caseInsensitive"] == nil else { continue } + #expect(operations.copyName( + for: items[0]["name"] as! String, isDirectory: items[0]["isDirectory"] as! Bool, + existing: request["existing"] as? [String] ?? [], duplicate: false + ) == (item["names"] as? [String])?.first, "\(item["id"] ?? "")") + } + } + + @Test + func historyEventsMatchTheSharedFixture() throws { + let fixture = try Self.fixture("history/event-plan-v1.json") + let core = RustCoreBridge() + for item in try #require(fixture["events"] as? [[String: Any]]) { + let event = try #require(LocalHistoryEvent(rawValue: item["event"] as! String)) + let expected = (item["snapshots"] as! [[String: String]]).map { + LocalHistorySnapshotPlan( + source: LocalHistorySnapshotPlan.Source(rawValue: $0["source"]!)!, + reason: LocalHistoryReason(rawValue: $0["reason"]!)! + ) + } + #expect(core.historyPlanEvent(event) == expected, "\(event)") + } + } + + private func rules(_ value: [String: Any]?) throws -> WorkspaceDirectoryRules { + let data = try JSONSerialization.data(withJSONObject: value ?? [:]) + struct Partial: Decodable { + var marks: [String: WorkspaceDirectoryMark] = [:] + var includedDirectories: [String] = [] + var previousRoles: [String: WorkspaceDirectoryMark] = [:] + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: Keys.self) + marks = try container.decodeIfPresent([String: WorkspaceDirectoryMark].self, forKey: .marks) ?? [:] + includedDirectories = try container.decodeIfPresent([String].self, forKey: .includedDirectories) ?? [] + previousRoles = try container.decodeIfPresent([String: WorkspaceDirectoryMark].self, forKey: .previousRoles) ?? [:] + } + enum Keys: String, CodingKey { case marks, includedDirectories, previousRoles } + } + let partial = try JSONDecoder().decode(Partial.self, from: data) + return WorkspaceDirectoryRules( + marks: partial.marks, includedDirectories: partial.includedDirectories, previousRoles: partial.previousRoles + ) + } + + @Test + func directoryRulesMatchTheSharedFixture() throws { + let fixture = try Self.fixture("workspace/directory-rules-v1.json") + for item in try #require(fixture["reduce"] as? [[String: Any]]) { + let request = try #require(item["request"] as? [String: Any]) + // macOS compares paths exactly; the case-insensitive cases belong to Windows. + guard request["caseInsensitive"] == nil else { continue } + let action = try #require(request["action"] as? [String: Any]) + let wire: WorkspaceDirectoryAction + switch action["kind"] as! String { + case "exclude": wire = .exclude + case "cancelExclusion": wire = .cancelExclusion + default: wire = .setRole(WorkspaceDirectoryMark(rawValue: action["role"] as! String)!) + } + let reduction = try #require(operations.reduceDirectoryAction( + wire, at: request["path"] as! String, rules: try rules(request["rules"] as? [String: Any]) + ), "\(item["id"] ?? "")") + let response = try #require(item["response"] as? [String: Any]) + #expect(reduction.changed == response["changed"] as? Bool, "\(item["id"] ?? "")") + let expected = try rules(response["rules"] as? [String: Any]) + #expect(reduction.rules.marks == expected.marks, "\(item["id"] ?? "")") + #expect(Set(reduction.rules.includedDirectories) == Set(expected.includedDirectories), "\(item["id"] ?? "")") + #expect(reduction.rules.previousRoles == expected.previousRoles, "\(item["id"] ?? "")") + } + for item in try #require(fixture["relocate"] as? [[String: Any]]) { + let request = try #require(item["request"] as? [String: Any]) + guard request["caseInsensitive"] == nil, request["target"] == nil else { continue } + let migrated = try #require(operations.relocateDirectoryRules( + try rules(request["rules"] as? [String: Any]), + moving: request["source"] as! String, to: request["destination"] as? String + ), "\(item["id"] ?? "")") + let response = try #require(item["response"] as? [String: Any]) + let expected = try rules(response["rules"] as? [String: Any]) + #expect(migrated.marks == expected.marks, "\(item["id"] ?? "")") + #expect(Set(migrated.includedDirectories) == Set(expected.includedDirectories), "\(item["id"] ?? "")") + #expect(migrated.previousRoles == expected.previousRoles, "\(item["id"] ?? "")") + } + } +} diff --git a/rust/lithe-core/src/project/file_operations.rs b/rust/lithe-core/src/project/file_operations.rs new file mode 100644 index 0000000000..36bdd2b23e --- /dev/null +++ b/rust/lithe-core/src/project/file_operations.rs @@ -0,0 +1,170 @@ +//! Copy names for Duplicate and Paste in the Project view. +//! +//! macOS and Windows both create the copy themselves (a native, no-overwrite create), but +//! neither decides what the copy is called. A host sends the names it has already seen +//! to exist in the destination directory and Core answers with names that are free +//! against those facts: `foo.txt` becomes `foo copy.txt`, then `foo copy 2.txt`. A file +//! name is split at its last dot, a directory name never is, and a leading dot +//! (`.gitignore`) is not an extension. +//! +//! The facts come from the platform, so the plan can be stale by the time the copy is +//! created. Hosts therefore loop: plan, create without replacing, and when the create +//! reports that the name now exists, add that name to `existing` and plan again. Core +//! never touches the file system. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashSet; + +/// Upper bound for the existing names one request may carry; larger requests are rejected. +const MAX_EXISTING_NAMES: usize = 100_000; +/// Upper bound for the items one paste may carry. +const MAX_PASTE_ITEMS: usize = 10_000; +/// Longest accepted file name in UTF-8 bytes. +const MAX_NAME_BYTES: usize = 4_096; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One item being copied; `name` is the last path component of its source. +struct CopyItem { + name: String, + is_directory: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Duplicate one item next to its source. +struct PlanDuplicateRequest { + name: String, + is_directory: bool, + /// Names the host has observed in the destination directory. + #[serde(default)] + existing: Vec, + /// Whether the destination volume treats names that differ only by case as equal. + #[serde(default)] + case_insensitive: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Paste several items into one destination directory. +struct PlanPasteRequest { + items: Vec, + #[serde(default)] + existing: Vec, + #[serde(default)] + case_insensitive: bool, +} + +/// Plans the name of a duplicate (`workspace.planDuplicate`). +/// +/// Returns `{ name }`. The name always carries the copy suffix, because a duplicate lives +/// next to its source. +pub(crate) fn plan_duplicate_command(payload: Value) -> Result { + let request: PlanDuplicateRequest = serde_json::from_value(payload).map_err(invalid)?; + let item = CopyItem { + name: request.name, + is_directory: request.is_directory, + }; + let names = plan( + std::slice::from_ref(&item), + &request.existing, + request.case_insensitive, + true, + )?; + Ok(json!({ "name": names[0] })) +} + +/// Plans the destination names of a paste (`workspace.planPaste`). +/// +/// Returns `{ names }`, one per item in request order. An item keeps its own name when +/// that name is free; otherwise it receives the copy suffix. Items planned earlier in the +/// same paste count as taken, so two sources with the same name never collide. +pub(crate) fn plan_paste_command(payload: Value) -> Result { + let request: PlanPasteRequest = serde_json::from_value(payload).map_err(invalid)?; + if request.items.len() > MAX_PASTE_ITEMS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many items to paste", + )); + } + let names = plan( + &request.items, + &request.existing, + request.case_insensitive, + false, + )?; + Ok(json!({ "names": names })) +} + +fn invalid(error: serde_json::Error) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid file copy request") + .with_details(error.to_string()) +} + +fn plan( + items: &[CopyItem], + existing: &[String], + case_insensitive: bool, + always_suffix: bool, +) -> Result, CoreError> { + if existing.len() > MAX_EXISTING_NAMES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many existing names", + )); + } + let key = |name: &str| { + if case_insensitive { + name.to_lowercase() + } else { + name.to_owned() + } + }; + let mut taken: HashSet = existing.iter().map(|name| key(name)).collect(); + let mut planned = Vec::with_capacity(items.len()); + for item in items { + validate_name(&item.name)?; + let (stem, extension) = split_name(&item.name, item.is_directory); + let chosen = std::iter::once(0usize) + .filter(|_| !always_suffix) + .chain(1..) + .map(|index| match index { + 0 => item.name.clone(), + 1 => format!("{stem} copy{extension}"), + _ => format!("{stem} copy {index}{extension}"), + }) + .find(|candidate| !taken.contains(&key(candidate))) + .expect("an unbounded sequence has a free name"); + taken.insert(key(&chosen)); + planned.push(chosen); + } + Ok(planned) +} + +fn validate_name(name: &str) -> Result<(), CoreError> { + if name.is_empty() + || name == "." + || name == ".." + || name.len() > MAX_NAME_BYTES + || name.contains(['/', '\\', '\0']) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid file name to copy", + )); + } + Ok(()) +} + +/// Splits `name` into the part before the copy suffix and the extension (with its dot). +fn split_name(name: &str, is_directory: bool) -> (&str, &str) { + if is_directory { + return (name, ""); + } + match name.rfind('.') { + Some(dot) if dot > 0 && dot + 1 < name.len() => name.split_at(dot), + _ => (name, ""), + } +} diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 54cc1231d3..22c7903fde 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -1,8 +1,10 @@ //! Project files, search, local history, and document rendering services. +pub(crate) mod directory_actions; mod document_lifecycle; pub(crate) mod document_save_batch; pub(crate) mod file_name_order; +pub(crate) mod file_operations; pub(crate) mod files; mod history; pub(crate) mod indexing; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index a84837ac16..d30248e0c8 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -308,6 +308,14 @@ pub enum CoreCommand { WorkspaceOrderEntries, /// Ranks host-supplied candidate names with the shared matcher (`workspace.rankNames`). WorkspaceRankNames, + /// Plans the name of a duplicated project item (`workspace.planDuplicate`). + WorkspacePlanDuplicate, + /// Plans the destination names of pasted project items (`workspace.planPaste`). + WorkspacePlanPaste, + /// Applies a directory role, exclusion or cancel action (`workspace.reduceDirectoryAction`). + WorkspaceReduceDirectoryAction, + /// Migrates directory rules after a move or delete (`workspace.relocateDirectoryRules`). + WorkspaceRelocateDirectoryRules, /// Filter ordinary workspace-relative event paths using shared defaults. WorkspaceFilterChanges, /// Finds Git repositories that belong to an opened workspace (`workspace.repositories`). @@ -350,6 +358,8 @@ pub enum CoreCommand { HistoryContent, /// Moves history after a workspace path relocation (`history.relocate`). HistoryRelocate, + /// Plans which snapshots an editing event records (`history.planEvent`). + HistoryPlanEvent, /// Changes the optional label of a history snapshot (`history.rename`). HistoryRename, /// Deletes one history snapshot (`history.delete`). @@ -967,6 +977,10 @@ impl CoreCommand { "workspace.listDirectories" => Some(Self::WorkspaceListDirectories), "workspace.orderEntries" => Some(Self::WorkspaceOrderEntries), "workspace.rankNames" => Some(Self::WorkspaceRankNames), + "workspace.planDuplicate" => Some(Self::WorkspacePlanDuplicate), + "workspace.planPaste" => Some(Self::WorkspacePlanPaste), + "workspace.reduceDirectoryAction" => Some(Self::WorkspaceReduceDirectoryAction), + "workspace.relocateDirectoryRules" => Some(Self::WorkspaceRelocateDirectoryRules), "workspace.repositories" => Some(Self::WorkspaceRepositories), "workspace.recentProjects.reduce" => Some(Self::WorkspaceRecentProjectsReduce), "workspace.searchIndex.warm" => Some(Self::WorkspaceSearchIndexWarm), @@ -988,6 +1002,7 @@ impl CoreCommand { "history.entries" => Some(Self::HistoryEntries), "history.content" => Some(Self::HistoryContent), "history.relocate" => Some(Self::HistoryRelocate), + "history.planEvent" => Some(Self::HistoryPlanEvent), "history.rename" => Some(Self::HistoryRename), "history.delete" => Some(Self::HistoryDelete), "history.ignorePreview" => Some(Self::HistoryIgnorePreview), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 80e6777e55..d870837a65 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -894,6 +894,26 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::project::name_ranking::rank_names_command, ), + CoreCommand::WorkspacePlanDuplicate => plugin_response( + id, + parsed.payload, + crate::project::file_operations::plan_duplicate_command, + ), + CoreCommand::WorkspacePlanPaste => plugin_response( + id, + parsed.payload, + crate::project::file_operations::plan_paste_command, + ), + CoreCommand::WorkspaceReduceDirectoryAction => plugin_response( + id, + parsed.payload, + crate::project::directory_actions::reduce_command, + ), + CoreCommand::WorkspaceRelocateDirectoryRules => plugin_response( + id, + parsed.payload, + crate::project::directory_actions::relocate_command, + ), CoreCommand::WorkspaceRepositories => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1161,6 +1181,9 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::HistoryPlanEvent => { + plugin_response(id, parsed.payload, crate::project::plan_event_command) + } CoreCommand::HistoryRelocate => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/file_operations.rs b/rust/lithe-core/src/tests/file_operations.rs new file mode 100644 index 0000000000..af1cd5ed51 --- /dev/null +++ b/rust/lithe-core/src/tests/file_operations.rs @@ -0,0 +1,70 @@ +use crate::execute_json; +use serde_json::{json, Value}; + +fn execute(command: &str, payload: Value) -> Value { + serde_json::from_str(&execute_json( + &json!({"id": "file-operations", "command": command, "payload": payload}).to_string(), + )) + .expect("core response should be JSON") +} + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/file-copy-names-v1.json" + )) + .expect("file copy names fixture should be JSON") +} + +#[test] +fn duplicate_names_match_the_shared_fixture() { + for case in fixture()["duplicate"].as_array().unwrap() { + let response = execute("workspace.planDuplicate", case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["id"]); + assert_eq!(response["data"]["name"], case["name"], "{}", case["id"]); + } +} + +#[test] +fn paste_names_match_the_shared_fixture() { + for case in fixture()["paste"].as_array().unwrap() { + let response = execute("workspace.planPaste", case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["id"]); + assert_eq!(response["data"]["names"], case["names"], "{}", case["id"]); + } +} + +#[test] +fn a_name_that_lost_a_race_is_planned_again_around_the_new_fact() { + let first = json!({"name": "a.txt", "isDirectory": false, "existing": ["a.txt"]}); + assert_eq!( + execute("workspace.planDuplicate", first)["data"]["name"], + "a copy.txt" + ); + let second = json!({ + "name": "a.txt", "isDirectory": false, "existing": ["a.txt", "a copy.txt"] + }); + assert_eq!( + execute("workspace.planDuplicate", second)["data"]["name"], + "a copy 2.txt" + ); +} + +#[test] +fn invalid_names_and_oversized_requests_are_rejected() { + for name in fixture()["invalidNames"].as_array().unwrap() { + let response = execute( + "workspace.planPaste", + json!({"items": [{"name": name, "isDirectory": false}]}), + ); + assert_eq!(response["ok"], false, "{name}"); + assert_eq!(response["error"]["code"], "invalid_request", "{name}"); + } + let existing = (0..100_001) + .map(|index| index.to_string()) + .collect::>(); + let response = execute( + "workspace.planDuplicate", + json!({"name": "a", "isDirectory": false, "existing": existing}), + ); + assert_eq!(response["error"]["code"], "invalid_request"); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index d52557b564..306ff17925 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -7,7 +7,9 @@ mod community_build_plugins; mod core_request_traces; mod declarative_plugins; mod detectors; +mod directory_actions; mod file_name_order; +mod file_operations; mod git; mod git_diff_blocks; mod git_fetch; @@ -19,6 +21,7 @@ mod git_repository_setup; mod git_untracked_directories; mod git_workspace_commit; mod github; +mod history_events; mod language_server_sessions; mod languages; mod marketplace_plugins; diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 1239eb1785..b51b172459 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1303,6 +1303,10 @@ package manager owns the download and Lithe does not infer bytes from logs. | `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, 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.planDuplicate` | Name the copy of one project item next to its source (`foo.txt` → `foo copy.txt`, then `foo copy 2.txt`) from the sibling names the host has seen to exist | +| `workspace.planPaste` | Name pasted project items in one destination directory: a free name is kept, a taken one gets the copy suffix, and earlier items of the same paste count as taken | +| `workspace.reduceDirectoryAction` | Apply "set role", "exclude" or "cancel exclusion" to a root's persisted directory rules and return the complete state to persist | +| `workspace.relocateDirectoryRules` | Move (or drop, after a delete) the directory rule keys of a renamed, moved or deleted subtree, optionally into another workspace root's rules | | `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.recentProjects.reduce` | Apply one record, remove, pin, import, or normalize action to the recent project list and return the retained order | @@ -1321,6 +1325,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `history.entries` | List valid history entries for one file or a workspace | | `history.content` | Read a stored history snapshot by relative storage path | | `history.relocate` | Move retained history for a file or directory subtree after a rename or move | +| `history.planEvent` | Plan which snapshots an editing event (save, auto-save, restore, manual, discarded unsaved text, external change, before rename, before batch replace) records and under which reason | | `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 | @@ -6314,6 +6319,34 @@ workspaces. Preferences and refresh completion use distinct events to avoid writ The native host injects the window identity for Git/file watcher ownership, shares paths and releases only the last holder; window destruction also revokes late registrations. +### Directory rules, copy names and history events + +`workspace.reduceDirectoryAction` takes `{ path, action, caseInsensitive?, rules }`, where +`path` is workspace-relative (`.` is the root), `action` is `{ kind: "setRole", role }`, +`{ kind: "exclude" }` or `{ kind: "cancelExclusion" }`, and `rules` is the root's persisted +`{ marks, includedDirectories, previousRoles }`. It returns `{ changed, rules }`; hosts persist +`rules` verbatim and skip the write when `changed` is false. Excluding remembers the replaced role in +`previousRoles` and canceling a manual exclusion restores it. `workspace.relocateDirectoryRules` +takes `{ source, destination?, caseInsensitive?, rules, target? }` and returns `{ rules, target }`: +keys at or below `source` leave `rules`, reappear under `destination` (in `target` for a move across +workspace roots) and are dropped when there is no destination. Request and response pairs are pinned +by `shared/fixtures/workspace/directory-rules-v1.json`. + +`workspace.planDuplicate` takes `{ name, isDirectory, existing?, caseInsensitive? }` and returns +`{ name }`; `workspace.planPaste` takes `{ items: [{ name, isDirectory }], existing?, caseInsensitive? }` +and returns `{ names }`. A file name is split at its last dot, a directory name and a leading-dot name +are not. Core never touches the file system: the host passes the names it has seen to exist, creates the +copy without replacing anything (`FileManager.copyItem`, which refuses an existing destination, on macOS; +`create_new` and `MoveFileEx` without replacement on Windows) and, when that fails because the name +appeared meanwhile, adds the name to `existing` and plans again. After 512 rounds the host reports +the stable `destination_exists` error. Cases are in `shared/fixtures/workspace/file-copy-names-v1.json`. + +`history.planEvent` takes `{ event }` and returns `{ snapshots: [{ source, reason }] }`. `source` says +whose text the host records: `beforeChange` (the text an event replaces), `afterChange` (the text it +produced) or `editorBuffer` (unsaved editor text that is discarded). A save or auto-save records +`beforeChange` and `afterChange`, so a snapshot of the saved state exists immediately. +Cases are in `shared/fixtures/history/event-plan-v1.json`. + The directory-action sequence contract is `shared/fixtures/workspace/directory-actions-v1.json`. Role changes retain include exceptions, explicit exclusion deletes only its own exception, and canceling Git exclusion preserves the current role. Only canceling a manual exclusion diff --git a/shared/fixtures/workspace/file-copy-names-v1.json b/shared/fixtures/workspace/file-copy-names-v1.json new file mode 100644 index 0000000000..5b7571d68c --- /dev/null +++ b/shared/fixtures/workspace/file-copy-names-v1.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "duplicate": [ + {"id": "file-with-extension", "request": {"name": "foo.txt", "isDirectory": false, "existing": ["foo.txt"]}, "name": "foo copy.txt"}, + {"id": "second-copy", "request": {"name": "foo.txt", "isDirectory": false, "existing": ["foo.txt", "foo copy.txt"]}, "name": "foo copy 2.txt"}, + {"id": "gap-is-reused", "request": {"name": "foo.txt", "isDirectory": false, "existing": ["foo.txt", "foo copy 2.txt"]}, "name": "foo copy.txt"}, + {"id": "only-last-extension", "request": {"name": "archive.tar.gz", "isDirectory": false, "existing": ["archive.tar.gz"]}, "name": "archive.tar copy.gz"}, + {"id": "no-extension", "request": {"name": "Makefile", "isDirectory": false}, "name": "Makefile copy"}, + {"id": "leading-dot-is-not-an-extension", "request": {"name": ".gitignore", "isDirectory": false}, "name": ".gitignore copy"}, + {"id": "trailing-dot-is-not-an-extension", "request": {"name": "name.", "isDirectory": false}, "name": "name. copy"}, + {"id": "directory-never-splits", "request": {"name": "my.pkg", "isDirectory": true}, "name": "my.pkg copy"}, + {"id": "case-sensitive-volume", "request": {"name": "a.txt", "isDirectory": false, "existing": ["A COPY.txt"]}, "name": "a copy.txt"}, + {"id": "case-insensitive-volume", "request": {"name": "a.txt", "isDirectory": false, "existing": ["A COPY.TXT"], "caseInsensitive": true}, "name": "a copy 2.txt"}, + {"id": "unicode-name", "request": {"name": "报告.md", "isDirectory": false, "existing": ["报告 copy.md"]}, "name": "报告 copy 2.md"} + ], + "paste": [ + {"id": "free-name-is-kept", "request": {"items": [{"name": "a.txt", "isDirectory": false}], "existing": ["b.txt"]}, "names": ["a.txt"]}, + {"id": "taken-name-gets-a-suffix", "request": {"items": [{"name": "a.txt", "isDirectory": false}], "existing": ["a.txt"]}, "names": ["a copy.txt"]}, + {"id": "same-name-twice-in-one-paste", "request": {"items": [{"name": "a.txt", "isDirectory": false}, {"name": "a.txt", "isDirectory": false}], "existing": []}, "names": ["a.txt", "a copy.txt"]}, + {"id": "later-item-avoids-earlier-suffix", "request": {"items": [{"name": "a.txt", "isDirectory": false}, {"name": "a copy.txt", "isDirectory": false}], "existing": ["a.txt"]}, "names": ["a copy.txt", "a copy copy.txt"]}, + {"id": "case-insensitive-paste", "request": {"items": [{"name": "Readme.MD", "isDirectory": false}], "existing": ["readme.md"], "caseInsensitive": true}, "names": ["Readme copy.MD"]}, + {"id": "directory-paste", "request": {"items": [{"name": "src.v2", "isDirectory": true}], "existing": ["src.v2", "src.v2 copy"]}, "names": ["src.v2 copy 2"]} + ], + "invalidNames": ["", ".", "..", "a/b", "a\\b"] +} diff --git a/shared/platform-feature-matrix/features/workspace-files.json b/shared/platform-feature-matrix/features/workspace-files.json index 4ad6016c4a..716254ec1f 100644 --- a/shared/platform-feature-matrix/features/workspace-files.json +++ b/shared/platform-feature-matrix/features/workspace-files.json @@ -22,7 +22,11 @@ "rust/lithe-core/src/project/files.rs", "rust/lithe-core/src/tests/project.rs", ".agents/notes/implemented/feature/2026-10-29-skip-python-environments-in-workspace-scans.md", - "macos/Tests/LitheTests/LitheCoreLogicTests.swift" + "macos/Tests/LitheTests/LitheCoreLogicTests.swift", + "rust/lithe-core/src/project/file_operations.rs", + "rust/lithe-core/src/project/directory_actions.rs", + "shared/fixtures/workspace/file-copy-names-v1.json", + "shared/fixtures/workspace/directory-rules-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -34,11 +38,17 @@ "windows/tauri/src/features/file-search/lib/file-search-api.test.ts", "rust/lithe-core/src/project/files.rs", "rust/lithe-core/src/tests/project.rs", - ".agents/notes/implemented/feature/2026-10-29-skip-python-environments-in-workspace-scans.md" + ".agents/notes/implemented/feature/2026-10-29-skip-python-environments-in-workspace-scans.md", + "rust/lithe-core/src/project/file_operations.rs", + "rust/lithe-core/src/project/directory_actions.rs", + "shared/fixtures/workspace/file-copy-names-v1.json", + "shared/fixtures/workspace/directory-rules-v1.json", + "windows/tauri/src/features/file-system/utils/copy-names.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 macOS 新建文件与目录复用居中无边框 LitheCenteredPopup,主窗口移动/关闭及失焦取消保持原行为;创建输入框保留原有 plain 样式、13pt 字体、IDEA NewItemSimplePopupPanel 的 32pt 高度及 13pt 输入留白,聚焦时无独立边框和底色;focusedNewItemFieldBlendsIntoPopup 验证深浅模式实际聚焦渲染,文件重命名输入框使用共享 litheSettingsTextField,检查初始焦点、中文输入、空名称拒绝与 Enter 提交。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS 项目树包压缩读取结果在有界内存中消费复用;验证同一包展开不重复列目录、并发展开与定位共用请求、取消一个等待者不影响另一个,以及文件事件、刷新、过滤规则变化、切换或关闭工作区后不应用旧结果;深层包名与全树转换保持一致。实际项目分别检查首次展开、再次展开、大目录、快速展开收起和定位,记录交互耗时;Windows 保留现有目录加载路径。 目录名称双击在第二次按下时开始展开,松开不重复切换;验证修饰键多选、拖动、文件双击、方向键、定位及切换 Dependencies 后的展开状态保留;无关 AppModel 更新不得重新生成可见树。 macOS 单子目录链自动连续展开且保留各级行,遇到文件、兄弟项、隐藏目录或空目录停止,手动收起不会被无关更新重开;空目录及仅含过滤项的目录无展开箭头,新增文件后恢复内容;验证原版 IDEA 明暗图标、Compose 文件图标和共享树间距。 目录名称只取自身 Git 状态,子文件的删除和冲突不得染红祖先目录,精确文件状态保持;检查本机 IDEA 展开箭头资源与实际留白。 单子目录链在一个后台请求中准备完成后一次发布,首次可见行与箭头状态直接使用整条链的展开集合,不出现 docker 再到 database-validation 的中间帧;校验分叉不预取、失败不发布半链、64 层深度上限和包压缩列表复用。 Project 顶部新增 IDEA 原版全部展开/全部收起:遍历所有分支但不重建文件库存;收起保留项目根并取消在途展开,取消/切换工作区不应用旧状态;定位、刷新、展开/收起随整个项目区悬停显示。 共享 IDEA SVG 渲染先生成 4x 透明位图再按原逻辑尺寸显示;真实 chevronRight/Down、expandAll/collapseAll 在深浅模式和 1x/2x 保持边缘对称,实际检查端点、转角及工具栏按钮原动作。 连续展开探测省略不可读或消失的子路径时,普通目录与压缩包保留可读父层和未加载末端,同批其他目录仍可发布;取消仍拒绝发布。 工作区扫描默认跳过 .venv、venv、__pycache__ 目录和 *.pyc 文件:在含 Python 虚拟环境的项目中打开工作区,确认快速打开、全局搜索和 Spring/MyBatis/Maven 索引不再出现这些路径,且普通源码文件不受影响;macOS 文件树不再显示 .venv,设置页内置隐藏规则列表包含这四项;Windows 文件树仍能看到 .venv(由前端隐藏路径设置控制),其 __pycache__ 与 *.pyc 默认已隐藏。Windows 请求快照时只取文件清单(includeTree: false),打开项目后观察内存不再因整棵树的序列化而出现额外高峰,实机验收 pending。 macOS 外部文件事件使用随库存更新的文件及祖先目录集合查询,规范化事件去重;运行 largeExternalEditBatchUsesInventoryMembershipWithoutRescanning 固定 4000 文件批次计时,以及连续增删、目录类型替换、工作区重置回归。大型真实工作区仍需采样确认主线程占用和空闲 CPU 回落。" + "verification": "新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 macOS 新建文件与目录复用居中无边框 LitheCenteredPopup,主窗口移动/关闭及失焦取消保持原行为;创建输入框保留原有 plain 样式、13pt 字体、IDEA NewItemSimplePopupPanel 的 32pt 高度及 13pt 输入留白,聚焦时无独立边框和底色;focusedNewItemFieldBlendsIntoPopup 验证深浅模式实际聚焦渲染,文件重命名输入框使用共享 litheSettingsTextField,检查初始焦点、中文输入、空名称拒绝与 Enter 提交。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS 项目树包压缩读取结果在有界内存中消费复用;验证同一包展开不重复列目录、并发展开与定位共用请求、取消一个等待者不影响另一个,以及文件事件、刷新、过滤规则变化、切换或关闭工作区后不应用旧结果;深层包名与全树转换保持一致。实际项目分别检查首次展开、再次展开、大目录、快速展开收起和定位,记录交互耗时;Windows 保留现有目录加载路径。 目录名称双击在第二次按下时开始展开,松开不重复切换;验证修饰键多选、拖动、文件双击、方向键、定位及切换 Dependencies 后的展开状态保留;无关 AppModel 更新不得重新生成可见树。 macOS 单子目录链自动连续展开且保留各级行,遇到文件、兄弟项、隐藏目录或空目录停止,手动收起不会被无关更新重开;空目录及仅含过滤项的目录无展开箭头,新增文件后恢复内容;验证原版 IDEA 明暗图标、Compose 文件图标和共享树间距。 目录名称只取自身 Git 状态,子文件的删除和冲突不得染红祖先目录,精确文件状态保持;检查本机 IDEA 展开箭头资源与实际留白。 单子目录链在一个后台请求中准备完成后一次发布,首次可见行与箭头状态直接使用整条链的展开集合,不出现 docker 再到 database-validation 的中间帧;校验分叉不预取、失败不发布半链、64 层深度上限和包压缩列表复用。 Project 顶部新增 IDEA 原版全部展开/全部收起:遍历所有分支但不重建文件库存;收起保留项目根并取消在途展开,取消/切换工作区不应用旧状态;定位、刷新、展开/收起随整个项目区悬停显示。 共享 IDEA SVG 渲染先生成 4x 透明位图再按原逻辑尺寸显示;真实 chevronRight/Down、expandAll/collapseAll 在深浅模式和 1x/2x 保持边缘对称,实际检查端点、转角及工具栏按钮原动作。 连续展开探测省略不可读或消失的子路径时,普通目录与压缩包保留可读父层和未加载末端,同批其他目录仍可发布;取消仍拒绝发布。 工作区扫描默认跳过 .venv、venv、__pycache__ 目录和 *.pyc 文件:在含 Python 虚拟环境的项目中打开工作区,确认快速打开、全局搜索和 Spring/MyBatis/Maven 索引不再出现这些路径,且普通源码文件不受影响;macOS 文件树不再显示 .venv,设置页内置隐藏规则列表包含这四项;Windows 文件树仍能看到 .venv(由前端隐藏路径设置控制),其 __pycache__ 与 *.pyc 默认已隐藏。Windows 请求快照时只取文件清单(includeTree: false),打开项目后观察内存不再因整棵树的序列化而出现额外高峰,实机验收 pending。 macOS 外部文件事件使用随库存更新的文件及祖先目录集合查询,规范化事件去重;运行 largeExternalEditBatchUsesInventoryMembershipWithoutRescanning 固定 4000 文件批次计时,以及连续增删、目录类型替换、工作区重置回归。大型真实工作区仍需采样确认主线程占用和空闲 CPU 回落。 复制与粘贴重名命名:两端统一为 Core 的 `foo copy.txt`、`foo copy 2.txt`(目录不拆扩展名),主机只报告目标目录里已存在的名字,并以不覆盖的方式创建;名字被并发占用时加入已知事实后重新规划,用尽后报稳定错误码 destination_exists。验证在含同名文件的目录里连续复制、粘贴和重复粘贴,确认不覆盖且名称按序递增。", + "notes": "复制名由 Core 的 workspace.planDuplicate/planPaste 决定(此前 macOS 和 Windows 宿主用 ` copy`,Windows 前端用 `_copy` 且只查内存树);Windows 本地复制和剪切粘贴在宿主里以 create_new 或 MoveFileEx 不替换方式创建,SSH 与 WSL 目录的复制仍由各自后端执行,名字依据当前树里已知的条目规划。" } diff --git a/windows/tauri/src-tauri/src/host.rs b/windows/tauri/src-tauri/src/host.rs index 1d49481c71..59bce72a8e 100644 --- a/windows/tauri/src-tauri/src/host.rs +++ b/windows/tauri/src-tauri/src/host.rs @@ -239,13 +239,16 @@ fn paste_clipboard(app: &AppHandle, target_directory: PathBuf) -> Result copy_path(&entry.path, &destination)?, - ClipboardOperation::Cut => { - fs::rename(&entry.path, &destination).map_err(|error| error.to_string())? - } - } + let destination = create_without_replacing( + &target_directory, + name, + entry.is_dir, + false, + |destination| match clipboard.operation { + ClipboardOperation::Copy => copy_path(&entry.path, destination), + ClipboardOperation::Cut => move_exclusive(&entry.path, destination), + }, + )?; pasted.push(PastedEntry { source_path: entry.path.clone(), destination_path: destination, @@ -281,46 +284,136 @@ fn clear_pasted_clipboard( } } -fn unique_destination(path: PathBuf) -> PathBuf { - if !path.exists() { - return path; +/// Most create attempts for one destination; each failed attempt adds a name that exists. +const MAX_NAME_ATTEMPTS: usize = 512; + +/// Asks Core for a free copy name given the names already known to exist. +fn plan_copy_name( + name: &str, + is_directory: bool, + existing: &[String], + duplicate: bool, +) -> Result { + let (command, payload) = if duplicate { + ( + "workspace.planDuplicate", + serde_json::json!({"name": name, "isDirectory": is_directory, "existing": existing, "caseInsensitive": true}), + ) + } else { + ( + "workspace.planPaste", + serde_json::json!({"items": [{"name": name, "isDirectory": is_directory}], "existing": existing, "caseInsensitive": true}), + ) + }; + let response = lithe_core::execute_json( + &serde_json::json!({"command": command, "payload": payload}).to_string(), + ); + let value: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + if value["ok"] != true { + return Err(value["error"]["message"] + .as_str() + .unwrap_or("Core rejected the copy name request") + .to_owned()); } - let parent = path.parent().unwrap_or_else(|| std::path::Path::new("")); - let stem = path - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("copy"); - let extension = path.extension().and_then(|value| value.to_str()); - for index in 1.. { - let suffix = if index == 1 { - " copy".into() - } else { - format!(" copy {index}") - }; - let mut name = format!("{stem}{suffix}"); - if let Some(extension) = extension { - name.push('.'); - name.push_str(extension); - } - let candidate = parent.join(name); - if !candidate.exists() { - return candidate; + let planned = if duplicate { + value["data"]["name"].as_str() + } else { + value["data"]["names"][0].as_str() + }; + planned + .map(str::to_owned) + .ok_or_else(|| "Core returned no copy name".to_owned()) +} + +/// Creates `name` inside `directory` without ever replacing an existing entry. +/// +/// Core picks the name from the names this loop has seen to exist; `create` must fail with +/// `AlreadyExists` instead of overwriting. A concurrent writer that takes the planned name +/// first only adds that name to the facts and the plan is made again, so the outcome is +/// either a fresh name or the stable `destination_exists` error. +fn create_without_replacing( + directory: &Path, + name: &std::ffi::OsStr, + is_directory: bool, + duplicate: bool, + mut create: impl FnMut(&Path) -> io::Result<()>, +) -> Result { + let name = name + .to_str() + .ok_or_else(|| "Clipboard source name is not valid Unicode".to_owned())?; + let mut existing: Vec = Vec::new(); + for _ in 0..MAX_NAME_ATTEMPTS { + let planned = plan_copy_name(name, is_directory, &existing, duplicate)?; + let destination = directory.join(&planned); + match create(&destination) { + Ok(()) => return Ok(destination), + Err(error) + if (error.kind() == io::ErrorKind::AlreadyExists || destination.exists()) + && !existing.contains(&planned) => + { + existing.push(planned); + } + Err(error) => return Err(error.to_string()), } } - unreachable!() + Err(format!( + "destination_exists: no free name for {name} in {}", + directory.display() + )) +} + +/// Moves `source` to `destination`, failing with `AlreadyExists` instead of replacing it. +/// Windows moves atomically without replacement; elsewhere the check precedes the rename. +#[cfg(windows)] +fn move_exclusive(source: &Path, destination: &Path) -> io::Result<()> { + crate::file_publication::publish_new_record(source, destination) +} + +#[cfg(not(windows))] +fn move_exclusive(source: &Path, destination: &Path) -> io::Result<()> { + if destination.symlink_metadata().is_ok() { + return Err(io::ErrorKind::AlreadyExists.into()); + } + fs::rename(source, destination) } -fn copy_path(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { +/// Copies a file or directory tree to a destination that must not exist yet. +fn copy_path(source: &Path, destination: &Path) -> io::Result<()> { if source.is_dir() { - fs::create_dir(destination).map_err(|error| error.to_string())?; - for entry in fs::read_dir(source).map_err(|error| error.to_string())? { - let entry = entry.map_err(|error| error.to_string())?; + fs::create_dir(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; copy_path(&entry.path(), &destination.join(entry.file_name()))?; } - } else { - fs::copy(source, destination).map_err(|error| error.to_string())?; + return Ok(()); } - Ok(()) + // Reserve the name exclusively, then fill the reservation we own. + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + fs::copy(source, destination).map(|_| ()).inspect_err(|_| { + let _ = fs::remove_file(destination); + }) +} + +/// Duplicates a file or directory next to itself and returns the new path. +#[tauri::command] +pub async fn duplicate_path(path: PathBuf) -> Result { + run_blocking(move || { + let directory = path + .parent() + .ok_or_else(|| "Duplicate source requires a parent directory".to_owned())? + .to_path_buf(); + let name = path + .file_name() + .ok_or_else(|| "Duplicate source requires a file name".to_owned())?; + let is_directory = path.is_dir(); + create_without_replacing(&directory, name, is_directory, true, |destination| { + copy_path(&path, destination) + }) + }) + .await } #[tauri::command] @@ -426,8 +519,9 @@ pub async fn create_app_window(app: AppHandle, request: Option) -> Result #[cfg(test)] mod tests { use super::{ - cli_payloads, copy_path, create_app_window, parse_wsl_automount_root, read_bounded, - read_local_file_bounded_from_path, unique_destination, WINDOW_TASKBAR_ICON, + cli_payloads, copy_path, create_app_window, create_without_replacing, + parse_wsl_automount_root, read_bounded, read_local_file_bounded_from_path, + WINDOW_TASKBAR_ICON, WSL_DEFAULT_AUTOMOUNT_ROOT, }; #[test] @@ -540,7 +634,25 @@ mod tests { fs::read_to_string(destination.join("nested/file.txt")).unwrap(), "content" ); - assert_eq!(unique_destination(destination), target.join("source copy")); + let again = create_without_replacing( + &target, + std::ffi::OsStr::new("source"), + true, + false, + |path| copy_path(&source, path), + ) + .unwrap(); + assert_eq!(again, target.join("source copy")); + let duplicate = create_without_replacing( + &target, + std::ffi::OsStr::new("source"), + true, + true, + |path| copy_path(&source, path), + ) + .unwrap(); + assert_eq!(duplicate, target.join("source copy 2")); + assert!(copy_path(&source, &destination).is_err()); fs::remove_dir_all(root).unwrap(); } @@ -845,18 +957,7 @@ pub async fn move_file(source_path: PathBuf, target_path: PathBuf) -> Result<(), } fn move_without_replacing(source_path: &Path, target_path: &Path) -> Result<(), String> { - #[cfg(windows)] - { - crate::file_publication::publish_new_record(source_path, target_path) - .map_err(|error| error.to_string()) - } - #[cfg(not(windows))] - { - if target_path.exists() { - return Err("Destination already exists".into()); - } - fs::rename(source_path, target_path).map_err(|error| error.to_string()) - } + move_exclusive(source_path, target_path).map_err(|error| error.to_string()) } #[tauri::command] diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index c082d3eb0b..ddd2235b33 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -287,6 +287,7 @@ fn main() { host::clipboard_set, host::clipboard_get, host::clipboard_paste, + host::duplicate_path, host::clipboard_clear, host::clipboard_sequence_number, agent_clipboard::agent_clipboard_file_paths, diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx index 8d73957b17..bc8b2506d0 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx @@ -1368,6 +1368,7 @@ function FileExplorerTreeComponent({ if (targetDir) { void pasteIntoExplorerDirectory({ targetDirectory: targetDir, + workspaceRoots: workspaceRootPaths, createFileInDirectory: onCreateNewFileInDirectory, refreshDirectory: onRefreshDirectory, onJavaClassCreated: (fileName) => { diff --git a/windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts b/windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts index ec3fe195de..38a9166be1 100644 --- a/windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts +++ b/windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts @@ -1,4 +1,6 @@ import { useFileClipboardStore } from "@/features/file-explorer/stores/file-explorer-clipboard.store"; +import { migrateWorkspaceDirectoryMarks } from "@/features/file-explorer/stores/directory-marks"; +import { relocateLocalHistory } from "@/features/local-history/api/local-history-api"; import { tryPasteJavaClassFromSystemClipboard, type CreateFileInDirectory, @@ -11,6 +13,8 @@ import { export async function pasteIntoExplorerDirectory(options: { targetDirectory: string; createFileInDirectory?: CreateFileInDirectory; + /** Workspace roots whose directory rules follow moved directories (cut and paste). */ + workspaceRoots?: readonly string[]; refreshDirectory?: (path: string, options?: { force?: boolean }) => void; onJavaClassCreated?: (fileName: string) => void; onJavaClassFailed?: (error: unknown) => void; @@ -21,7 +25,22 @@ export async function pasteIntoExplorerDirectory(options: { const clipboardActions = useFileClipboardStore.getState().actions; if (clipboard) { - await clipboardActions.paste(targetDirectory); + const pasted = await clipboardActions.paste(targetDirectory); + if (clipboard.operation === "cut") { + // A cut moves the files, so their retained history and directory rules move with them. + for (const entry of pasted) { + try { + await relocateLocalHistory(entry.source_path, entry.destination_path); + await migrateWorkspaceDirectoryMarks( + options.workspaceRoots ?? [], + entry.source_path, + entry.destination_path, + ); + } catch (error) { + console.warn("Failed to carry history and directory rules to the pasted path:", error); + } + } + } refreshDirectory?.(targetDirectory, { force: true }); return "files"; } diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index 26228075db..1347b60e2c 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -1,10 +1,11 @@ import { bindWorkspaceIndexingRoots } from "@/features/file-explorer/stores/workspace-indexing"; import { migrateWorkspaceDirectoryMarks } from "@/features/file-explorer/stores/directory-marks"; +import { relocateLocalHistory } from "@/features/local-history/api/local-history-api"; import { loadFileContent } from "../controllers/workspace-session-restore"; import { invoke } from "@/platform/tauri-core"; import { LspOperationLog } from "@/platform/lsp-session-lifecycle"; import { basename, dirname, extname, join } from "@tauri-apps/api/path"; -import { copyFile } from "@tauri-apps/plugin-fs"; +import { planDuplicateName } from "../utils/copy-names"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { immer } from "zustand/middleware/immer"; import type { StoreApi } from "zustand"; @@ -2514,12 +2515,13 @@ const createFileSystemStore = (workspaceId: string): StoreApi + !!findFileInTree( + get().files, + `remote://${remoteInfo.connectionId}${dir === "/" ? `/${name}` : `${dir}/${name}`}`, + ), + }); + const finalPath = dir === "/" ? `/${finalName}` : `${dir}/${finalName}`; await invoke("ssh_copy_path", { connectionId: remoteInfo.connectionId, @@ -2862,24 +2858,15 @@ const createFileSystemStore = (workspaceId: string): StoreApi (dir === "/" ? `/${name}` : `${dir}/${name}`); + const finalName = await planDuplicateName({ + name: base, + isDirectory: fileEntry.isDir, + exists: (name) => + !!findFileInTree(get().files, buildWslPath(wslInfo.distro, linuxPathOf(name))), + }); + const finalLinuxPath = linuxPathOf(finalName); + const finalPath = buildWslPath(wslInfo.distro, finalLinuxPath); await invoke("wsl_copy_path", { distro: wslInfo.distro, @@ -2904,37 +2891,20 @@ const createFileSystemStore = (workspaceId: string): StoreApi { - if (counter === 0) { - return `${nameWithoutExt}_copy.${ext}`; - } - return `${nameWithoutExt}_copy_${counter}.${ext}`; - }; - - do { - finalName = generateCopyName(); - finalPath = joinPath(dir, finalName); - counter++; - } while (findFileInTree(get().files, finalPath)); - - await copyFile(path, finalPath); + // The host plans the name with Core against the directory on disk and creates the + // copy without replacing anything, so a name taken meanwhile is planned again. + const finalPath = await invoke("duplicate_path", { path }); + const finalName = await basename(finalPath); + const dir = await dirname(finalPath); const newFile: FileEntry = { name: finalName, path: finalPath, - isDir: false, + isDir: originalFile.isDir, + children: originalFile.isDir ? [] : undefined, }; set((state) => { @@ -2982,13 +2952,14 @@ const createFileSystemStore = (workspaceId: string): StoreApi 0) { try { - migrateWorkspaceDirectoryMarks(marksRoots, path, targetPath); + await migrateWorkspaceDirectoryMarks(marksRoots, path, targetPath); } catch (error) { toast.error(getCurrentTranslator()("project.markFailed"), { description: String(error), }); } } + await relocateHistoryAfterMove(path, targetPath); set((state) => { state.files = updateFileInTree(state.files, path, (item) => ({ ...item, @@ -3203,4 +3174,13 @@ scopedFileSystemStore = createWorkspaceScopedStore( createFileSystemStore, ); +/** Retained local history follows a file or directory that was renamed or moved. */ +async function relocateHistoryAfterMove(source: string, destination: string): Promise { + try { + await relocateLocalHistory(source, destination); + } catch (error) { + console.warn("Failed to relocate local history:", error); + } +} + export const useFileSystemStore = scopedFileSystemStore; diff --git a/windows/tauri/src/features/file-system/utils/copy-names.test.ts b/windows/tauri/src/features/file-system/utils/copy-names.test.ts new file mode 100644 index 0000000000..f2bb0b1299 --- /dev/null +++ b/windows/tauri/src/features/file-system/utils/copy-names.test.ts @@ -0,0 +1,41 @@ +import { expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import { planDuplicateName } from "./copy-names"; + +test("a planned name that exists becomes a fact and Core plans around it", async () => { + const requests: unknown[] = []; + // Core's answers for foo.txt, as pinned by shared/fixtures/workspace/file-copy-names-v1.json. + const answers = ["foo copy.txt", "foo copy 2.txt"]; + const invoke = spyOn(native, "invoke").mockImplementation((async (_command: string, payload?: unknown) => { + requests.push(JSON.parse(JSON.stringify(payload))); + return { name: answers[requests.length - 1] }; + }) as typeof native.invoke); + try { + const name = await planDuplicateName({ + name: "foo.txt", + isDirectory: false, + exists: (candidate) => candidate === "foo copy.txt", + caseInsensitive: true, + }); + expect(name).toBe("foo copy 2.txt"); + expect(requests).toEqual([ + { name: "foo.txt", isDirectory: false, existing: [], caseInsensitive: true }, + { name: "foo.txt", isDirectory: false, existing: ["foo copy.txt"], caseInsensitive: true }, + ]); + } finally { + invoke.mockRestore(); + } +}); + +test("a destination that never frees up fails with the stable destination_exists code", async () => { + let count = 0; + const invoke = spyOn(native, "invoke").mockImplementation((async () => ({ name: `n${count++}` })) as typeof native.invoke); + try { + await expect( + planDuplicateName({ name: "a", isDirectory: false, exists: () => true }), + ).rejects.toThrow("destination_exists"); + expect(count).toBe(512); + } finally { + invoke.mockRestore(); + } +}); diff --git a/windows/tauri/src/features/file-system/utils/copy-names.ts b/windows/tauri/src/features/file-system/utils/copy-names.ts new file mode 100644 index 0000000000..5c1085efba --- /dev/null +++ b/windows/tauri/src/features/file-system/utils/copy-names.ts @@ -0,0 +1,31 @@ +import { invoke } from "@/platform/tauri-core"; + +/** Each failed probe adds one existing name, so this bounds a single destination search. */ +const MAX_NAME_ATTEMPTS = 512; + +/** + * Names the copy of a project item. Rust Core owns the rule (`workspace.planDuplicate`): + * `foo.txt` becomes `foo copy.txt`, then `foo copy 2.txt`. This host only reports which + * planned names already exist, and Core plans again around them. + * + * @param exists Reports whether a planned sibling name is taken in the destination. + */ +export async function planDuplicateName(options: { + name: string; + isDirectory: boolean; + exists: (name: string) => boolean | Promise; + caseInsensitive?: boolean; +}): Promise { + const existing: string[] = []; + for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt += 1) { + const { name } = await invoke<{ name: string }>("workspace.planDuplicate", { + name: options.name, + isDirectory: options.isDirectory, + existing, + caseInsensitive: options.caseInsensitive ?? false, + }); + if (!(await options.exists(name))) return name; + existing.push(name); + } + throw new Error(`destination_exists: no free name for ${options.name}`); +} From 2013e2e72c00c33288decf75d2fe4f08873a2bb5 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 05:20:13 -0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(core):=20#1662=20WS-07=20SSH/WSL=20?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=8C=89=E7=9B=AE=E5=BD=95=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E8=A7=84=E5=88=92=E5=90=8D=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复制前用 ssh_read_directory/wsl_read_directory 读出目标目录的真实名字,后端返回 destination_exists 时重新规划;补充测试和功能矩阵说明。 Co-Authored-By: Claude Opus 5.5 --- .../features/workspace-files.json | 2 +- .../file-system/stores/file-system.store.ts | 63 ++++++++++------- .../file-system/utils/copy-names.test.ts | 68 +++++++++++++------ .../features/file-system/utils/copy-names.ts | 31 ++++++--- 4 files changed, 106 insertions(+), 58 deletions(-) diff --git a/shared/platform-feature-matrix/features/workspace-files.json b/shared/platform-feature-matrix/features/workspace-files.json index 716254ec1f..8f9c20b9cd 100644 --- a/shared/platform-feature-matrix/features/workspace-files.json +++ b/shared/platform-feature-matrix/features/workspace-files.json @@ -50,5 +50,5 @@ }, "owner": "Workspace", "verification": "新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 macOS 新建文件与目录复用居中无边框 LitheCenteredPopup,主窗口移动/关闭及失焦取消保持原行为;创建输入框保留原有 plain 样式、13pt 字体、IDEA NewItemSimplePopupPanel 的 32pt 高度及 13pt 输入留白,聚焦时无独立边框和底色;focusedNewItemFieldBlendsIntoPopup 验证深浅模式实际聚焦渲染,文件重命名输入框使用共享 litheSettingsTextField,检查初始焦点、中文输入、空名称拒绝与 Enter 提交。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS 项目树包压缩读取结果在有界内存中消费复用;验证同一包展开不重复列目录、并发展开与定位共用请求、取消一个等待者不影响另一个,以及文件事件、刷新、过滤规则变化、切换或关闭工作区后不应用旧结果;深层包名与全树转换保持一致。实际项目分别检查首次展开、再次展开、大目录、快速展开收起和定位,记录交互耗时;Windows 保留现有目录加载路径。 目录名称双击在第二次按下时开始展开,松开不重复切换;验证修饰键多选、拖动、文件双击、方向键、定位及切换 Dependencies 后的展开状态保留;无关 AppModel 更新不得重新生成可见树。 macOS 单子目录链自动连续展开且保留各级行,遇到文件、兄弟项、隐藏目录或空目录停止,手动收起不会被无关更新重开;空目录及仅含过滤项的目录无展开箭头,新增文件后恢复内容;验证原版 IDEA 明暗图标、Compose 文件图标和共享树间距。 目录名称只取自身 Git 状态,子文件的删除和冲突不得染红祖先目录,精确文件状态保持;检查本机 IDEA 展开箭头资源与实际留白。 单子目录链在一个后台请求中准备完成后一次发布,首次可见行与箭头状态直接使用整条链的展开集合,不出现 docker 再到 database-validation 的中间帧;校验分叉不预取、失败不发布半链、64 层深度上限和包压缩列表复用。 Project 顶部新增 IDEA 原版全部展开/全部收起:遍历所有分支但不重建文件库存;收起保留项目根并取消在途展开,取消/切换工作区不应用旧状态;定位、刷新、展开/收起随整个项目区悬停显示。 共享 IDEA SVG 渲染先生成 4x 透明位图再按原逻辑尺寸显示;真实 chevronRight/Down、expandAll/collapseAll 在深浅模式和 1x/2x 保持边缘对称,实际检查端点、转角及工具栏按钮原动作。 连续展开探测省略不可读或消失的子路径时,普通目录与压缩包保留可读父层和未加载末端,同批其他目录仍可发布;取消仍拒绝发布。 工作区扫描默认跳过 .venv、venv、__pycache__ 目录和 *.pyc 文件:在含 Python 虚拟环境的项目中打开工作区,确认快速打开、全局搜索和 Spring/MyBatis/Maven 索引不再出现这些路径,且普通源码文件不受影响;macOS 文件树不再显示 .venv,设置页内置隐藏规则列表包含这四项;Windows 文件树仍能看到 .venv(由前端隐藏路径设置控制),其 __pycache__ 与 *.pyc 默认已隐藏。Windows 请求快照时只取文件清单(includeTree: false),打开项目后观察内存不再因整棵树的序列化而出现额外高峰,实机验收 pending。 macOS 外部文件事件使用随库存更新的文件及祖先目录集合查询,规范化事件去重;运行 largeExternalEditBatchUsesInventoryMembershipWithoutRescanning 固定 4000 文件批次计时,以及连续增删、目录类型替换、工作区重置回归。大型真实工作区仍需采样确认主线程占用和空闲 CPU 回落。 复制与粘贴重名命名:两端统一为 Core 的 `foo copy.txt`、`foo copy 2.txt`(目录不拆扩展名),主机只报告目标目录里已存在的名字,并以不覆盖的方式创建;名字被并发占用时加入已知事实后重新规划,用尽后报稳定错误码 destination_exists。验证在含同名文件的目录里连续复制、粘贴和重复粘贴,确认不覆盖且名称按序递增。", - "notes": "复制名由 Core 的 workspace.planDuplicate/planPaste 决定(此前 macOS 和 Windows 宿主用 ` copy`,Windows 前端用 `_copy` 且只查内存树);Windows 本地复制和剪切粘贴在宿主里以 create_new 或 MoveFileEx 不替换方式创建,SSH 与 WSL 目录的复制仍由各自后端执行,名字依据当前树里已知的条目规划。" + "notes": "复制名由 Core 的 workspace.planDuplicate/planPaste 决定(此前 macOS 和 Windows 宿主用 ` copy`,Windows 前端用 `_copy` 且只查内存树);Windows 本地复制和剪切粘贴在宿主里以 create_new 或 MoveFileEx 不替换方式创建,SSH 与 WSL 复制先用 ssh_read_directory/wsl_read_directory 读出目标目录实际存在的名字再交给 Core 规划,不再依赖内存树;后端返回 destination_exists 时加入已知事实后重新规划。当前仓库内没有注册 ssh_copy_path/wsl_copy_path 的 Rust 实现,其是否不覆盖由后端决定;读目录与创建之间仍有一个竞态窗口(取决于一次远程目录读取加一次复制的耗时),后端不报 destination_exists 时无法发现。" } diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index 1347b60e2c..35691245e2 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -5,7 +5,7 @@ import { loadFileContent } from "../controllers/workspace-session-restore"; import { invoke } from "@/platform/tauri-core"; import { LspOperationLog } from "@/platform/lsp-session-lifecycle"; import { basename, dirname, extname, join } from "@tauri-apps/api/path"; -import { planDuplicateName } from "../utils/copy-names"; +import { duplicateWithPlannedName } from "../utils/copy-names"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { immer } from "zustand/middleware/immer"; import type { StoreApi } from "zustand"; @@ -2814,22 +2814,26 @@ const createFileSystemStore = (workspaceId: string): StoreApi - !!findFileInTree( - get().files, - `remote://${remoteInfo.connectionId}${dir === "/" ? `/${name}` : `${dir}/${name}`}`, - ), - }); - const finalPath = dir === "/" ? `/${finalName}` : `${dir}/${finalName}`; - - await invoke("ssh_copy_path", { - connectionId: remoteInfo.connectionId, - sourcePath: remoteInfo.remotePath, - targetPath: finalPath, - isDirectory: fileEntry.isDir, + listNames: async () => + ( + await invoke>("ssh_read_directory", { + connectionId: remoteInfo.connectionId, + path: dir, + }) + ).map((entry) => entry.name), + create: async (name) => { + const targetPath = dir === "/" ? `/${name}` : `${dir}/${name}`; + await invoke("ssh_copy_path", { + connectionId: remoteInfo.connectionId, + sourcePath: remoteInfo.remotePath, + targetPath, + isDirectory: fileEntry.isDir, + }); + return targetPath; + }, }); const newEntry: FileEntry = { @@ -2859,22 +2863,29 @@ const createFileSystemStore = (workspaceId: string): StoreApi (dir === "/" ? `/${name}` : `${dir}/${name}`); - const finalName = await planDuplicateName({ + const { name: finalName, result: finalLinuxPath } = await duplicateWithPlannedName({ name: base, isDirectory: fileEntry.isDir, - exists: (name) => - !!findFileInTree(get().files, buildWslPath(wslInfo.distro, linuxPathOf(name))), + listNames: async () => + ( + await invoke>("wsl_read_directory", { + distro: wslInfo.distro, + path: dir, + }) + ).map((entry) => entry.name), + create: async (name) => { + const targetPath = linuxPathOf(name); + await invoke("wsl_copy_path", { + distro: wslInfo.distro, + sourcePath: wslInfo.linuxPath, + targetPath, + isDirectory: fileEntry.isDir, + }); + return targetPath; + }, }); - const finalLinuxPath = linuxPathOf(finalName); const finalPath = buildWslPath(wslInfo.distro, finalLinuxPath); - await invoke("wsl_copy_path", { - distro: wslInfo.distro, - sourcePath: wslInfo.linuxPath, - targetPath: finalLinuxPath, - isDirectory: fileEntry.isDir, - }); - const newEntry: FileEntry = { name: finalName, path: finalPath, diff --git a/windows/tauri/src/features/file-system/utils/copy-names.test.ts b/windows/tauri/src/features/file-system/utils/copy-names.test.ts index f2bb0b1299..76d7311f15 100644 --- a/windows/tauri/src/features/file-system/utils/copy-names.test.ts +++ b/windows/tauri/src/features/file-system/utils/copy-names.test.ts @@ -1,40 +1,68 @@ import { expect, spyOn, test } from "bun:test"; import * as native from "@/platform/tauri-core"; -import { planDuplicateName } from "./copy-names"; +import { duplicateWithPlannedName } from "./copy-names"; -test("a planned name that exists becomes a fact and Core plans around it", async () => { - const requests: unknown[] = []; - // Core's answers for foo.txt, as pinned by shared/fixtures/workspace/file-copy-names-v1.json. - const answers = ["foo copy.txt", "foo copy 2.txt"]; - const invoke = spyOn(native, "invoke").mockImplementation((async (_command: string, payload?: unknown) => { - requests.push(JSON.parse(JSON.stringify(payload))); - return { name: answers[requests.length - 1] }; +/** Core's rule for foo.txt, as pinned by shared/fixtures/workspace/file-copy-names-v1.json. */ +const coreAnswer = (existing: string[]) => + ["foo copy.txt", "foo copy 2.txt", "foo copy 3.txt"].find((name) => !existing.includes(name))!; + +const installCore = (requests: unknown[]) => + spyOn(native, "invoke").mockImplementation((async (_command: string, payload?: unknown) => { + const request = JSON.parse(JSON.stringify(payload)) as { existing: string[] }; + requests.push(request); + return { name: coreAnswer(request.existing) }; }) as typeof native.invoke); + +test("names the provider lists but the in-memory tree lacks are avoided", async () => { + const requests: unknown[] = []; + const invoke = installCore(requests); try { - const name = await planDuplicateName({ + const created: string[] = []; + const { name } = await duplicateWithPlannedName({ name: "foo.txt", isDirectory: false, - exists: (candidate) => candidate === "foo copy.txt", - caseInsensitive: true, + listNames: async () => ["foo.txt", "foo copy.txt"], + create: async (candidate) => { created.push(candidate); }, }); expect(name).toBe("foo copy 2.txt"); + expect(created).toEqual(["foo copy 2.txt"]); expect(requests).toEqual([ - { name: "foo.txt", isDirectory: false, existing: [], caseInsensitive: true }, - { name: "foo.txt", isDirectory: false, existing: ["foo copy.txt"], caseInsensitive: true }, + { name: "foo.txt", isDirectory: false, existing: ["foo.txt", "foo copy.txt"], caseInsensitive: false }, ]); } finally { invoke.mockRestore(); } }); -test("a destination that never frees up fails with the stable destination_exists code", async () => { - let count = 0; - const invoke = spyOn(native, "invoke").mockImplementation((async () => ({ name: `n${count++}` })) as typeof native.invoke); +test("destination_exists from the backend adds the name to the facts and plans again", async () => { + const requests: unknown[] = []; + const invoke = installCore(requests); + try { + const attempts: string[] = []; + const { name } = await duplicateWithPlannedName({ + name: "foo.txt", + isDirectory: false, + listNames: async () => ["foo.txt"], + create: async (candidate) => { + attempts.push(candidate); + if (candidate === "foo copy.txt") throw new Error("destination_exists: foo copy.txt"); + }, + }); + expect(attempts).toEqual(["foo copy.txt", "foo copy 2.txt"]); + expect(name).toBe("foo copy 2.txt"); + expect((requests[1] as { existing: string[] }).existing).toEqual(["foo.txt", "foo copy.txt"]); + } finally { + invoke.mockRestore(); + } +}); + +test("other backend errors and an endless race are not retried forever", async () => { + const invoke = spyOn(native, "invoke").mockImplementation((async () => ({ name: "x" })) as typeof native.invoke); try { - await expect( - planDuplicateName({ name: "a", isDirectory: false, exists: () => true }), - ).rejects.toThrow("destination_exists"); - expect(count).toBe(512); + await expect(duplicateWithPlannedName({ + name: "a", isDirectory: false, listNames: async () => [], + create: async () => { throw new Error("permission denied"); }, + })).rejects.toThrow("permission denied"); } finally { invoke.mockRestore(); } diff --git a/windows/tauri/src/features/file-system/utils/copy-names.ts b/windows/tauri/src/features/file-system/utils/copy-names.ts index 5c1085efba..c8075c190c 100644 --- a/windows/tauri/src/features/file-system/utils/copy-names.ts +++ b/windows/tauri/src/features/file-system/utils/copy-names.ts @@ -3,20 +3,25 @@ import { invoke } from "@/platform/tauri-core"; /** Each failed probe adds one existing name, so this bounds a single destination search. */ const MAX_NAME_ATTEMPTS = 512; +/** True when a provider reports that the destination name appeared after it was planned. */ +export const isDestinationExists = (error: unknown): boolean => + String(error instanceof Error ? error.message : error).includes("destination_exists"); + /** - * Names the copy of a project item. Rust Core owns the rule (`workspace.planDuplicate`): - * `foo.txt` becomes `foo copy.txt`, then `foo copy 2.txt`. This host only reports which - * planned names already exist, and Core plans again around them. - * - * @param exists Reports whether a planned sibling name is taken in the destination. + * Names the copy of a project item and runs `create` with it. Rust Core owns the rule + * (`workspace.planDuplicate`): `foo.txt` becomes `foo copy.txt`, then `foo copy 2.txt`. + * `listNames` reads the names that actually exist in the destination directory from the + * provider (never the in-memory tree). When `create` fails with `destination_exists` the + * name is added to the facts and Core plans again. */ -export async function planDuplicateName(options: { +export async function duplicateWithPlannedName(options: { name: string; isDirectory: boolean; - exists: (name: string) => boolean | Promise; + listNames: () => Promise; + create: (name: string) => Promise; caseInsensitive?: boolean; -}): Promise { - const existing: string[] = []; +}): Promise<{ name: string; result: T }> { + const existing = [...(await options.listNames())]; for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt += 1) { const { name } = await invoke<{ name: string }>("workspace.planDuplicate", { name: options.name, @@ -24,8 +29,12 @@ export async function planDuplicateName(options: { existing, caseInsensitive: options.caseInsensitive ?? false, }); - if (!(await options.exists(name))) return name; - existing.push(name); + try { + return { name, result: await options.create(name) }; + } catch (error) { + if (!isDestinationExists(error) || existing.includes(name)) throw error; + existing.push(name); + } } throw new Error(`destination_exists: no free name for ${options.name}`); }