From d347022394c0c402af8e96b498b74c22c6c2e95a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 21:54:40 +0800 Subject: [PATCH 01/28] feat(core): add git worktree management operations --- rust/lithe-core/src/git/mod.rs | 182 ++++++++++++++++++++ rust/lithe-core/src/protocol/command.rs | 3 + rust/lithe-core/src/protocol/contracts.rs | 31 ++++ rust/lithe-core/src/runtime/dispatcher.rs | 19 +- rust/lithe-core/src/tests/git.rs | 200 ++++++++++++++++++++++ shared/contracts/application-boundary.md | 2 +- shared/contracts/rust-core-api.md | 17 +- shared/fixtures/git/worktrees-v1.json | 31 ++++ 8 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 shared/fixtures/git/worktrees-v1.json diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 4f5328cd3..ce5e44113 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -10,6 +10,7 @@ use crate::protocol::{ GitHistoryResponse, GitIntegrationPreflightResponse, GitOperationStateResponse, GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, GitStashResponse, GitStashesResponse, GitStatusResponse, GitWatchContextResponse, + GitWorktreeResponse, GitWorktreesResponse, }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; @@ -45,6 +46,13 @@ pub struct GitWatchContextRequest { pub root: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for all worktrees registered in the current repository. +pub struct GitWorktreesRequest { + pub root: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request for branch and publication state used by pull request creation. @@ -849,6 +857,18 @@ fn write_with_trace(request: GitWriteRequest) -> Result return mutations::checkout_and_rebase(&root, request), "createWorktree" => return create_worktree(&root, &request), + "removeWorktree" | "lockWorktree" | "unlockWorktree" => { + return mutate_worktree(&root, &request) + } + "pruneWorktrees" => { + arguments = vec![ + "worktree".into(), + "prune".into(), + "--verbose".into(), + "--expire=now".into(), + ] + } + "repairWorktrees" => arguments = vec!["worktree".into(), "repair".into()], "fetch" => arguments = vec!["fetch".into(), "--all".into(), "--prune".into()], // Strategy comes from the caller because only the user can decide whether a // divergent history should be merged or replayed. Absent a choice we stay on @@ -4250,6 +4270,121 @@ fn push_with_upstream_warning( pushed } +/// Returns one metadata-only snapshot for every registered worktree. +pub fn worktrees(request: GitWorktreesRequest) -> Result { + let root = validate_root(&request.root)?; + Ok(GitWorktreesResponse { + worktrees: list_worktrees(&root)?, + }) +} + +fn list_worktrees(root: &str) -> Result, CoreError> { + let arguments = vec![ + "worktree".to_string(), + "list".to_string(), + "--porcelain".to_string(), + "-z".to_string(), + ]; + let response = execute_git_readonly(root, &arguments, None)?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git worktree listing failed") + .with_details(response.output), + ); + } + let current_root = repository_root(root)? + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(root)); + let mut records = Vec::new(); + let mut fields = Vec::new(); + for field in response.stdout.split('\0') { + if field.is_empty() { + if !fields.is_empty() { + records.push(parse_worktree_record( + &fields, + records.is_empty(), + ¤t_root, + )?); + fields.clear(); + } + } else { + fields.push(field); + } + } + if !fields.is_empty() { + records.push(parse_worktree_record( + &fields, + records.is_empty(), + ¤t_root, + )?); + } + // Git currently emits the primary worktree first, but sorting here makes + // that display contract explicit and stable across Git versions. + records.sort_by(|left, right| { + right + .is_primary + .cmp(&left.is_primary) + .then_with(|| left.path.cmp(&right.path)) + }); + Ok(records) +} + +fn parse_worktree_record( + fields: &[&str], + is_primary: bool, + current_root: &Path, +) -> Result { + let path = fields + .iter() + .find_map(|field| field.strip_prefix("worktree ")) + .filter(|path| !path.is_empty()) + .ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git returned an invalid worktree record", + ) + })?; + let head = fields + .iter() + .find_map(|field| field.strip_prefix("HEAD ")) + .unwrap_or_default() + .to_string(); + let branch = fields + .iter() + .find_map(|field| field.strip_prefix("branch ")) + .map(str::to_string); + let value_after_marker = |marker: &str| { + fields.iter().find_map(|field| { + if *field == marker { + Some(None) + } else { + field + .strip_prefix(&format!("{marker} ")) + .map(|value| Some(value.to_string())) + } + }) + }; + let lock = value_after_marker("locked"); + let prunable = value_after_marker("prunable"); + let reported_path = PathBuf::from(path); + let comparison_path = reported_path + .canonicalize() + .unwrap_or_else(|_| reported_path.clone()); + Ok(GitWorktreeResponse { + path: path.to_string(), + head, + branch, + is_current: comparison_path == current_root, + is_primary, + is_bare: fields.contains(&"bare"), + is_detached: fields.contains(&"detached"), + is_locked: lock.is_some(), + lock_reason: lock.flatten(), + is_prunable: prunable.is_some(), + prune_reason: prunable.flatten(), + }) +} + fn create_worktree(root: &str, request: &GitWriteRequest) -> Result { let branch = validated_branch_name(root, request.name.as_deref())?; let destination = required_text(request.destination.as_deref(), "worktree destination")?; @@ -4281,6 +4416,53 @@ fn create_worktree(root: &str, request: &GitWriteRequest) -> Result Result { + let destination = required_text(request.destination.as_deref(), "worktree destination")?; + if destination.starts_with('-') || destination.contains(['\0', '\n', '\r']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git worktree destination", + )); + } + let entries = list_worktrees(root)?; + let target = entries + .iter() + .find(|entry| entry.path == destination) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The selected path is not a registered Git worktree", + ) + })?; + if request.operation == "removeWorktree" && (target.is_current || target.is_primary) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current or primary Git worktree cannot be removed", + )); + } + if request.operation == "removeWorktree" && target.is_locked { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unlock the Git worktree before removing it", + )); + } + + let mut arguments = vec!["worktree".to_string()]; + match request.operation.as_str() { + "removeWorktree" => { + arguments.push("remove".into()); + if request.force { + arguments.push("--force".into()); + } + } + "lockWorktree" => arguments.push("lock".into()), + "unlockWorktree" => arguments.push("unlock".into()), + _ => unreachable!("caller restricts worktree mutations"), + } + arguments.extend(["--".into(), destination]); + execute_git(root, &arguments, None) +} + fn configure_branch_upstream( root: &str, local_branch: &str, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 624fa88de..7b9fb24a1 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -195,6 +195,8 @@ pub enum CoreCommand { GitStatus, /// Resolves paths a Git-aware watcher must observe (`git.watchContext`). GitWatchContext, + /// Lists worktrees registered for the repository (`git.worktrees`). + GitWorktrees, /// Describes the checked-out branch or detached worktree for PR creation (`git.pullRequestContext`). GitPullRequestContext, /// Executes a caller-supplied argument vector without a shell (`git.command`). @@ -325,6 +327,7 @@ impl CoreCommand { "spring.index" => Some(Self::SpringIndex), "git.status" => Some(Self::GitStatus), "git.watchContext" => Some(Self::GitWatchContext), + "git.worktrees" => Some(Self::GitWorktrees), "git.pullRequestContext" => Some(Self::GitPullRequestContext), "git.command" => Some(Self::GitCommand), "git.write" => Some(Self::GitWrite), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index eac88a016..baad30190 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -399,6 +399,37 @@ pub struct GitWatchContextResponse { pub git_common_directory: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One checkout registered in a repository's shared worktree metadata. +pub struct GitWorktreeResponse { + /// Absolute checkout path reported by Git. Linked worktrees may live outside the opened workspace. + pub path: String, + /// Commit currently checked out by this worktree. + pub head: String, + /// Fully qualified local branch reference, absent for detached or bare worktrees. + pub branch: Option, + /// Whether this is the worktree from which the request was made. + pub is_current: bool, + /// Whether this is the repository's primary worktree. + pub is_primary: bool, + pub is_bare: bool, + pub is_detached: bool, + pub is_locked: bool, + /// Human-readable lock reason supplied to Git, when present. + pub lock_reason: Option, + pub is_prunable: bool, + /// Git's explanation for why the registration can be pruned. + pub prune_reason: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deterministically ordered worktrees registered for one repository. +pub struct GitWorktreesResponse { + pub worktrees: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Local or remote Git reference in display-ready form. diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 537d247c8..fd38e4fb7 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -10,7 +10,8 @@ use crate::git::{ GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryRequest, GitIntegrationPreflightRequest, GitOperationStateRequest, GitPullPreflightRequest, GitPullRequestContextRequest, GitPushPreviewRequest, - GitStashesRequest, GitStatusRequest, GitWatchContextRequest, GitWriteRequest, + GitStashesRequest, GitStatusRequest, GitWatchContextRequest, GitWorktreesRequest, + GitWriteRequest, }; use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::languages::{ @@ -1429,6 +1430,22 @@ fn execute(request: &str) -> CoreResponse { } } + CoreCommand::GitWorktrees => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git worktrees request") + .with_details(error.to_string()) + }) + .and_then(git::worktrees) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git worktrees should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitPullRequestContext => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index f32fe04d7..02149581b 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1363,6 +1363,206 @@ fn git_write_creates_a_tracked_worktree_from_an_unambiguous_complete_remote_refe ); } +#[test] +fn git_worktrees_lists_primary_linked_and_locked_metadata() { + struct RemovePathsOnDrop(Vec); + + impl Drop for RemovePathsOnDrop { + fn drop(&mut self) { + for path in self.0.iter().rev() { + let _ = fs::remove_dir_all(path); + } + } + } + + let root = git_write_repository("git-worktrees-list"); + let destination = root.with_extension("linked worktree-测试"); + let _cleanup = RemovePathsOnDrop(vec![root.clone(), destination.clone()]); + commit_history_file(&root, "base.txt", "base\n", "initial"); + assert!(history_git( + &root, + &[ + "worktree", + "add", + "-q", + "-b", + "feature/linked", + destination.to_str().expect("test path should be UTF-8"), + ], + ) + .status + .success()); + assert!(history_git( + &root, + &[ + "worktree", + "lock", + "--reason", + "Codex task", + destination.to_str().expect("test path should be UTF-8"), + ], + ) + .status + .success()); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "git-worktrees-list", + "command": "git.worktrees", + "payload": { "root": root } + })) + .expect("worktrees request should encode"), + )) + .expect("worktrees response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + let worktrees = response["data"]["worktrees"] + .as_array() + .expect("worktrees should be an array"); + assert_eq!(worktrees.len(), 2, "{response:?}"); + assert_eq!(worktrees[0]["isPrimary"], true); + assert_eq!(worktrees[0]["isCurrent"], true); + assert_eq!( + worktrees[1]["path"], + destination + .canonicalize() + .expect("linked worktree should canonicalize") + .to_string_lossy() + .as_ref() + ); + assert_eq!(worktrees[1]["branch"], "refs/heads/feature/linked"); + assert_eq!(worktrees[1]["isLocked"], true); + assert_eq!(worktrees[1]["lockReason"], "Codex task"); +} + +#[test] +fn git_write_manages_only_registered_non_primary_worktrees() { + struct RemovePathsOnDrop(Vec); + + impl Drop for RemovePathsOnDrop { + fn drop(&mut self) { + for path in self.0.iter().rev() { + let _ = fs::remove_dir_all(path); + } + } + } + + let root = git_write_repository("git-worktree-mutations"); + let destination = root.with_extension("managed-worktree"); + let stale_destination = root.with_extension("stale-worktree"); + let _cleanup = RemovePathsOnDrop(vec![ + root.clone(), + destination.clone(), + stale_destination.clone(), + ]); + commit_history_file(&root, "base.txt", "base\n", "initial"); + for (path, branch) in [ + (&destination, "feature/managed"), + (&stale_destination, "feature/stale"), + ] { + assert!(history_git( + &root, + &[ + "worktree", + "add", + "-q", + "-b", + branch, + path.to_str().expect("test path should be UTF-8"), + ], + ) + .status + .success()); + } + let registered_destination = destination + .canonicalize() + .expect("managed worktree should canonicalize"); + let registered_root = root.canonicalize().expect("repository should canonicalize"); + + let locked = git_write_request( + &root, + "lockWorktree", + serde_json::json!({ "destination": registered_destination }), + ); + assert_eq!(locked["ok"], true, "{locked:?}"); + assert_eq!(locked["data"]["exitCode"], 0, "{locked:?}"); + let rejected_locked_remove = git_write_request( + &root, + "removeWorktree", + serde_json::json!({ "destination": registered_destination }), + ); + assert_eq!( + rejected_locked_remove["ok"], true, + "{rejected_locked_remove:?}" + ); + assert_eq!( + rejected_locked_remove["data"]["operationError"]["code"], "invalid_request", + "{rejected_locked_remove:?}" + ); + let unlocked = git_write_request( + &root, + "unlockWorktree", + serde_json::json!({ "destination": registered_destination }), + ); + assert_eq!(unlocked["data"]["exitCode"], 0, "{unlocked:?}"); + + fs::write(destination.join("dirty.txt"), "dirty\n").expect("worktree should be writable"); + let dirty_remove = git_write_request( + &root, + "removeWorktree", + serde_json::json!({ "destination": registered_destination }), + ); + assert_eq!(dirty_remove["ok"], true, "{dirty_remove:?}"); + assert_ne!(dirty_remove["data"]["exitCode"], 0, "{dirty_remove:?}"); + let forced_remove = git_write_request( + &root, + "removeWorktree", + serde_json::json!({ "destination": registered_destination, "force": true }), + ); + assert_eq!(forced_remove["data"]["exitCode"], 0, "{forced_remove:?}"); + assert!(!destination.exists()); + + let primary_remove = git_write_request( + &root, + "removeWorktree", + serde_json::json!({ "destination": registered_root }), + ); + assert_eq!( + primary_remove["data"]["operationError"]["code"], "invalid_request", + "{primary_remove:?}" + ); + let unrelated_remove = git_write_request( + &root, + "removeWorktree", + serde_json::json!({ "destination": root.with_extension("unregistered") }), + ); + assert_eq!( + unrelated_remove["data"]["operationError"]["code"], "invalid_request", + "{unrelated_remove:?}" + ); + + let repair = git_write_request(&root, "repairWorktrees", serde_json::json!({})); + assert_eq!(repair["data"]["exitCode"], 0, "{repair:?}"); + + fs::remove_dir_all(&stale_destination).expect("stale worktree should be removable"); + let prune = git_write_request(&root, "pruneWorktrees", serde_json::json!({})); + assert_eq!(prune["data"]["exitCode"], 0, "{prune:?}"); + let listed: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "git-worktrees-after-prune", + "command": "git.worktrees", + "payload": { "root": root } + })) + .expect("worktrees request should encode"), + )) + .expect("worktrees response should be JSON"); + assert_eq!( + listed["data"]["worktrees"].as_array().map(Vec::len), + Some(1), + "{listed:?}" + ); +} + #[test] fn git_write_executes_checkout_preflight_clone_and_validation() { let root = git_write_repository("git-write-checkout-workflows"); diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 06986513a..8b5e1ca06 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -25,7 +25,7 @@ verification scripts are the executable source of boundary checks. | Workspace | visible snapshot, relative paths, file metadata, deterministic ordering | workspace root selection, native dialogs, and watchers | | Documents | relative-path validation, UTF-8 read/write results, dirty/save state | native file integration and external-change notifications | | Search | query matching, deterministic result ordering, symbols, and replacement preview | workspace lifecycle and optional index persistence | -| Git | changes, commits, branches, diffs, history, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment | +| Git | changes, commits, branches, diffs, history, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 665fc1ff5..e239f6121 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -139,6 +139,7 @@ stable error code and a user-facing message: | `runConfig.createLaunchPlan` | Project one effective configuration into a platform-neutral Run or Debug plan | | `git.status` | Resolve the repository, current branch, and working-tree changes | | `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | +| `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | | `git.pullRequestContext` | Resolve worktree-aware PR branch defaults, publication state, and uncommitted-change state | | `git.command` | Execute one argument-based Git operation and return its arguments, streams, exit code, and ordered subprocess invocations | | `git.write` | Validate and execute shared Git mutations such as stage, commit, branch, checkout, remote sync, clone, and stash | @@ -165,6 +166,14 @@ are one-based. `git.status.repositoryRoot` may be an absolute path when the opened workspace is a subdirectory of the repository; all Git change paths are relative to that repository root. `git.status.ahead` and `behind` report the current branch's tracking counts and are zero when no upstream is configured. +`git.worktrees.worktrees` is ordered with the primary worktree first and then +by path. Each entry contains `path`, `head`, nullable `branch`, `isCurrent`, +`isPrimary`, `isBare`, `isDetached`, `isLocked`, nullable `lockReason`, +`isPrunable`, and nullable `pruneReason`. The path is absolute because linked +worktrees may live outside the opened workspace; clients must treat it as an +opaque native boundary value and must not persist it as a portable identifier. +Core reads the list with one porcelain operation and does not run status in +each checkout. For a rename or copy, each change uses the destination as `path` and preserves the source as `originalPath`; platform mutations that act on the Git entry pass both paths back to Core. @@ -248,6 +257,7 @@ response retains the invocation trace and includes the failure as `stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `ignore`, `exclude`, `cherryPick`, `revert`, `reset`, `editCommitMessage`, `deleteCommit`, `squashCommits`, `createBranch`, `publishBranch`, `renameBranch`, `setUpstream`, `unsetUpstream`, `deleteBranch`, `merge`, `rebase`, `createWorktree`, +`removeWorktree`, `lockWorktree`, `unlockWorktree`, `repairWorktrees`, `pruneWorktrees`, `fetch`, `pull`, `push`, `checkout`, `checkoutAndRebase`, `checkoutRevision`, `clone`, `stashPush`, `stashApply`, `stashPop`, `stashDrop`, `deleteRemoteBranch`, `operationContinue`, `operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, @@ -261,7 +271,12 @@ stash references, and operation-specific required fields before invoking Git. upstream ambiguous. `createWorktree` likewise requires a typed reference; for a remote reference Core executes one `git worktree add --track -b` mutation using the complete remote ref, so branch creation, checkout, and tracking setup do not -form separate platform-visible success states. +form separate platform-visible success states. Worktree mutations re-read Git's +registered list and reject arbitrary paths. Removal rejects the current, +primary, or locked worktree; dirty worktrees require an explicit `force` value. +`repairWorktrees` refreshes administrative links after a repository or worktree +has moved. `pruneWorktrees` removes registrations whose checkout is already missing and +does not recursively delete an arbitrary directory. Successful process launch returns `{ "arguments": string[], "output": string, "stdout": string, "stderr": string, "exitCode": number, "invocations": GitCommandInvocation[], "operationError": CoreError?, "stashRestore": diff --git a/shared/fixtures/git/worktrees-v1.json b/shared/fixtures/git/worktrees-v1.json new file mode 100644 index 000000000..9b38c62c2 --- /dev/null +++ b/shared/fixtures/git/worktrees-v1.json @@ -0,0 +1,31 @@ +{ + "protocolVersion": 1, + "worktrees": [ + { + "path": "/example/project", + "head": "1111111111111111111111111111111111111111", + "branch": "refs/heads/main", + "isCurrent": true, + "isPrimary": true, + "isBare": false, + "isDetached": false, + "isLocked": false, + "lockReason": null, + "isPrunable": false, + "pruneReason": null + }, + { + "path": "/example/worktrees/feature-core", + "head": "2222222222222222222222222222222222222222", + "branch": "refs/heads/feature/core", + "isCurrent": false, + "isPrimary": false, + "isBare": false, + "isDetached": false, + "isLocked": true, + "lockReason": "active task", + "isPrunable": false, + "pruneReason": null + } + ] +} From d4f658aecea30860550ef2572a73d46fcd5f46fd Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 21:54:56 +0800 Subject: [PATCH 02/28] feat(macos): connect worktree operations to git feature model --- .../Lithe/Core/Rust/RustCoreBridge.swift | 41 +++++ .../Lithe/Core/Rust/RustGitOperations.swift | 48 ++++++ .../AppModel/AppModel+FeatureState.swift | 13 ++ .../AppModel/AppModel+GitOperations.swift | 43 +++++ .../Application/GitFeatureModel.swift | 158 ++++++++++++++++++ .../LitheGitModule/Models/GitModels.swift | 78 +++++++++ .../LitheGitModule/Services/GitService.swift | 69 +++++++- 7 files changed, 449 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 8e3247df0..c74c46d65 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1116,6 +1116,40 @@ struct RustCoreBridge: Sendable { } } + struct GitWorktreesPayload: Decodable, Sendable { + struct Worktree: Decodable, Sendable { + let path: String + let head: String + let branch: String? + let isCurrent: Bool + let isPrimary: Bool + let isBare: Bool + let isDetached: Bool + let isLocked: Bool + let lockReason: String? + let isPrunable: Bool + let pruneReason: String? + + func makeModel() -> GitWorktree { + GitWorktree( + path: path, + head: head, + branch: branch, + isCurrent: isCurrent, + isPrimary: isPrimary, + isBare: isBare, + isDetached: isDetached, + isLocked: isLocked, + lockReason: lockReason, + isPrunable: isPrunable, + pruneReason: pruneReason + ) + } + } + + let worktrees: [Worktree] + } + struct GitPullRequestContextPayload: Decodable, Sendable { let currentBranch: String? let suggestedBaseBranch: String? @@ -2593,6 +2627,13 @@ struct RustCoreBridge: Sendable { return try? result.get() } + func gitWorktrees(at rootURL: URL) -> GitWorktreesPayload? { + execute( + command: "git.worktrees", + payload: GitStatusRequest(root: rootURL.standardizedFileURL.path) + ) + } + func gitPullRequestContext( at rootURL: URL ) -> Result { diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 69cc7c356..26c76a9eb 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -145,6 +145,50 @@ struct RustGitOperations: GitOperations, Sendable { ) } + func createWorktree( + named name: String, + from reference: GitReference, + at destination: URL, + repositoryRoot: URL + ) -> GitProcessResult? { + write( + at: repositoryRoot, + operation: "createWorktree", + gitReference: reference, + name: name, + destination: destination + ) + } + + func removeWorktree( + _ worktree: GitWorktree, + force: Bool, + at rootURL: URL + ) -> GitProcessResult? { + write( + at: rootURL, + operation: "removeWorktree", + destination: worktree.url, + force: force + ) + } + + func lockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "lockWorktree", destination: worktree.url) + } + + func unlockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "unlockWorktree", destination: worktree.url) + } + + func repairWorktrees(at rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "repairWorktrees") + } + + func pruneWorktrees(at rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "pruneWorktrees") + } + func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "renameBranch", gitReference: reference, name: name) } @@ -327,6 +371,10 @@ struct RustGitOperations: GitOperations, Sendable { core.gitWatchContext(at: rootURL)?.makeContext() } + func worktrees(at rootURL: URL) -> [GitWorktree]? { + core.gitWorktrees(at: rootURL)?.worktrees.map { $0.makeModel() } + } + func diffPatch( at rootURL: URL, pathspecs: [String], diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 2c3c45a56..ce259903f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -153,6 +153,19 @@ extension AppModel { } var gitStashes: [GitStash] { gitFeatureIfActive?.gitStashes ?? [] } var gitShelves: [GitShelfEntry] { gitFeatureIfActive?.gitShelves ?? [] } + var gitWorktrees: [GitWorktree] { gitFeatureIfActive?.gitWorktrees ?? [] } + var gitWorktreeLoadState: GitWorktreeLoadState { + gitFeatureIfActive?.gitWorktreeLoadState ?? .idle + } + var gitWorktreeInspection: GitWorktreeInspection? { + gitFeatureIfActive?.gitWorktreeInspection + } + var gitWorktreeInspectionLoadState: GitWorktreeInspectionLoadState { + gitFeatureIfActive?.gitWorktreeInspectionLoadState ?? .idle + } + var isPerformingWorktreeOperation: Bool { + gitFeatureIfActive?.isPerformingWorktreeOperation ?? false + } var gitSaveChangesPolicy: GitSaveChangesPolicy { settings.gitSaveChangesPolicy } var isPerformingStashOperation: Bool { gitFeatureIfActive?.isPerformingStashOperation ?? false } var isPerformingShelfOperation: Bool { gitFeatureIfActive?.isPerformingShelfOperation ?? false } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift index 27f3fd63f..85752db55 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift @@ -136,6 +136,49 @@ extension AppModel { await gitFeature.refreshGit() } + func refreshGitWorktrees() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.refreshWorktrees() + } + + func inspectGitWorktree(_ worktree: GitWorktree) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.inspectWorktree(worktree) + } + + func createGitWorktree( + named name: String, + from reference: GitReference, + at destination: URL + ) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.createWorktree(named: name, from: reference, at: destination) + } + + func removeGitWorktree(_ worktree: GitWorktree, force: Bool) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.removeWorktree(worktree, force: force) + } + + func setGitWorktreeLocked(_ worktree: GitWorktree, locked: Bool) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.setWorktreeLocked(worktree, locked: locked) + } + + func pruneGitWorktrees() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.pruneWorktrees() + } + + func repairGitWorktrees() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.repairWorktrees() + } + + func chooseGitWorktreeParentDirectory() -> URL? { + platformUI.chooseDirectory(title: "Choose Worktree Parent", prompt: "Choose") + } + func stageSelectedChange() async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.stageSelectedChange() diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 9d4cd2c50..371e1043d 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -14,8 +14,13 @@ package final class GitFeatureModel: ObservableObject { @Published private var pendingStagingStates: [GitChange.ID: Bool] = [:] @Published package private(set) var gitStashes: [GitStash] = [] @Published package private(set) var gitShelves: [GitShelfEntry] = [] + @Published package private(set) var gitWorktrees: [GitWorktree] = [] + @Published package private(set) var gitWorktreeLoadState = GitWorktreeLoadState.idle + @Published package private(set) var gitWorktreeInspection: GitWorktreeInspection? + @Published package private(set) var gitWorktreeInspectionLoadState = GitWorktreeInspectionLoadState.idle @Published package private(set) var isPerformingStashOperation = false @Published package private(set) var isPerformingShelfOperation = false + @Published package private(set) var isPerformingWorktreeOperation = false @Published package private(set) var gitRepositoryRoot: URL? @Published package private(set) var currentBranch = "No Git" @Published package var selectedChange: GitChange? @@ -94,6 +99,7 @@ package final class GitFeatureModel: ObservableObject { private let snapshotProvider: @Sendable (URL) async -> GitSnapshot? private let stashesProvider: @Sendable (URL) async -> [GitStash] private let operationStateProvider: @Sendable (URL) async -> GitOperationState? + private let worktreesProvider: @Sendable (URL) async -> [GitWorktree]? private let diffDocumentProvider: @Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument private var workspaceURLProvider: (@MainActor () -> URL?)? private var isGitLogVisibleProvider: (@MainActor () -> Bool)? @@ -114,6 +120,8 @@ package final class GitFeatureModel: ObservableObject { private var gitConsoleRepositoryGeneration: UInt64 = 0 private var loadingLineChangeURLs: Set = [] private var lineChangeHunks: [URL: [String: DiffHunk]] = [:] + private var worktreeRequestGeneration: UInt64 = 0 + private var worktreeInspectionRequestGeneration: UInt64 = 0 private static let commitFilesPrefetchRadius = 4 @@ -123,6 +131,7 @@ package final class GitFeatureModel: ObservableObject { snapshotProvider: (@Sendable (URL) async -> GitSnapshot?)? = nil, stashesProvider: (@Sendable (URL) async -> [GitStash])? = nil, operationStateProvider: (@Sendable (URL) async -> GitOperationState?)? = nil, + worktreesProvider: (@Sendable (URL) async -> [GitWorktree]?)? = nil, diffDocumentProvider: (@Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument)? = nil ) { self.service = service @@ -131,6 +140,7 @@ package final class GitFeatureModel: ObservableObject { self.snapshotProvider = snapshotProvider ?? { await service.snapshot(for: $0) } self.stashesProvider = stashesProvider ?? { await service.stashes(at: $0) } self.operationStateProvider = operationStateProvider ?? { await service.operationState(at: $0) } + self.worktreesProvider = worktreesProvider ?? { await service.worktrees(at: $0) } self.diffDocumentProvider = diffDocumentProvider ?? { await service.diffDocument(for: $0, whitespace: $1) } @@ -167,6 +177,7 @@ package final class GitFeatureModel: ObservableObject { package var hasActiveModuleWork: Bool { isPerformingStashOperation || isPerformingShelfOperation + || isPerformingWorktreeOperation || isLoadingDiff || isRefreshingGit || isCommitting @@ -184,6 +195,12 @@ package final class GitFeatureModel: ObservableObject { pendingStagingStates = [:] gitStashes = [] gitShelves = [] + gitWorktrees = [] + gitWorktreeLoadState = .idle + worktreeRequestGeneration &+= 1 + gitWorktreeInspection = nil + gitWorktreeInspectionLoadState = .idle + worktreeInspectionRequestGeneration &+= 1 gitOperationState = nil pendingPullStrategy = nil pendingIntegrationConflict = nil @@ -195,6 +212,7 @@ package final class GitFeatureModel: ObservableObject { deferredSavedChanges = nil isPerformingStashOperation = false isPerformingShelfOperation = false + isPerformingWorktreeOperation = false gitRepositoryRoot = nil currentBranch = "No Git" selectedChange = nil @@ -322,6 +340,9 @@ package final class GitFeatureModel: ObservableObject { if gitRepositoryRoot != snapshot.repositoryRoot { clearGitCommitFilesCache() gitRepositoryRoot = snapshot.repositoryRoot + gitWorktrees = [] + gitWorktreeLoadState = .idle + worktreeRequestGeneration &+= 1 gitConsoleRepositoryGeneration &+= 1 isLoadingInitialGitConsoleEntry = false hasLoadedInitialGitConsoleEntry = false @@ -1601,6 +1622,143 @@ package final class GitFeatureModel: ObservableObject { isLoadingBranchComparison = false } + package func refreshWorktrees() async { + worktreeRequestGeneration &+= 1 + let generation = worktreeRequestGeneration + guard let repositoryRoot = gitRepositoryRoot else { + gitWorktrees = [] + gitWorktreeLoadState = .idle + return + } + gitWorktreeLoadState = .loading + let worktrees = await worktreesProvider(repositoryRoot) + guard generation == worktreeRequestGeneration, + gitRepositoryRoot?.standardizedFileURL == repositoryRoot.standardizedFileURL, + !Task.isCancelled else { return } + if let worktrees { + gitWorktrees = worktrees + gitWorktreeLoadState = .ready + } else { + gitWorktrees = [] + gitWorktreeLoadState = .failed("Could not load Git worktrees") + } + } + + package func inspectWorktree(_ worktree: GitWorktree) async { + worktreeInspectionRequestGeneration &+= 1 + let generation = worktreeInspectionRequestGeneration + guard !worktree.isPrunable else { + gitWorktreeInspection = nil + gitWorktreeInspectionLoadState = .failed("The checkout path does not exist") + return + } + gitWorktreeInspectionLoadState = .loading + let reference = gitReferences.first { $0.fullName == worktree.branch } + let inspection = await service.inspectWorktree(worktree, reference: reference) + guard generation == worktreeInspectionRequestGeneration, + !Task.isCancelled else { return } + if let inspection { + gitWorktreeInspection = inspection + gitWorktreeInspectionLoadState = .ready + } else { + gitWorktreeInspection = nil + gitWorktreeInspectionLoadState = .failed("Could not inspect this worktree") + } + } + + package func createWorktree( + named rawName: String, + from reference: GitReference, + at destination: URL + ) async { + guard let gitRepositoryRoot, !isPerformingWorktreeOperation else { return } + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + notify?(String(localized: "Enter a branch name", bundle: .main)) + return + } + isPerformingWorktreeOperation = true + let result = await withGitOperation { + await service.createWorktree( + named: name, + from: reference, + at: destination.standardizedFileURL, + repositoryRoot: gitRepositoryRoot + ) + } + isPerformingWorktreeOperation = false + if result.succeeded { + notify?(String( + format: String(localized: "Created worktree for %@", bundle: .main), + name + )) + await refreshWorktrees() + await refreshGitHistory() + } else { + notify?(trimmedMessage(result)) + } + } + + package func removeWorktree(_ worktree: GitWorktree, force: Bool) async { + guard let gitRepositoryRoot, !isPerformingWorktreeOperation else { return } + isPerformingWorktreeOperation = true + let result = await withGitOperation { + await service.removeWorktree(worktree, force: force, at: gitRepositoryRoot) + } + isPerformingWorktreeOperation = false + notify?(result.succeeded + ? String( + format: String(localized: "Removed %@", bundle: .main), + worktree.displayName + ) + : trimmedMessage(result)) + await refreshWorktrees() + } + + package func setWorktreeLocked(_ worktree: GitWorktree, locked: Bool) async { + guard let gitRepositoryRoot, !isPerformingWorktreeOperation else { return } + isPerformingWorktreeOperation = true + let result = await withGitOperation { + if locked { + await service.lockWorktree(worktree, at: gitRepositoryRoot) + } else { + await service.unlockWorktree(worktree, at: gitRepositoryRoot) + } + } + isPerformingWorktreeOperation = false + let success = String( + format: String( + localized: locked ? "Locked %@" : "Unlocked %@", + bundle: .main + ), + worktree.displayName + ) + notify?(result.succeeded ? success : trimmedMessage(result)) + await refreshWorktrees() + } + + package func pruneWorktrees() async { + guard let gitRepositoryRoot, !isPerformingWorktreeOperation else { return } + isPerformingWorktreeOperation = true + let result = await withGitOperation { await service.pruneWorktrees(at: gitRepositoryRoot) } + isPerformingWorktreeOperation = false + notify?(result.succeeded + ? String(localized: "Pruned stale worktree records", bundle: .main) + : trimmedMessage(result)) + await refreshWorktrees() + } + + package func repairWorktrees() async { + guard let gitRepositoryRoot, !isPerformingWorktreeOperation else { return } + isPerformingWorktreeOperation = true + let result = await withGitOperation { await service.repairWorktrees(at: gitRepositoryRoot) } + isPerformingWorktreeOperation = false + notify?(result.succeeded + ? String(localized: "Repaired worktree records", bundle: .main) + : trimmedMessage(result)) + await refreshWorktrees() + } + package func createBranch(named rawName: String, from reference: GitReference, checkout: Bool) async { guard let gitRepositoryRoot else { return } let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 236b2a4a9..e244ea2bf 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -10,6 +10,84 @@ package struct GitSnapshot: Sendable { package init(repositoryRoot: URL, branch: String, changes: [GitChange]) { self.repositoryRoot = repositoryRoot; self.branch = branch; self.changes = changes } } +package enum GitWorktreeLoadState: Equatable, Sendable { + case idle + case loading + case ready + case failed(String) +} + +package enum GitWorktreeInspectionLoadState: Equatable, Sendable { + case idle + case loading + case ready + case failed(String) +} + +package struct GitWorktreeInspection: Sendable { + package let worktreeID: String + package let changes: [GitChange] + package let commits: [GitCommit] + + package init(worktreeID: String, changes: [GitChange], commits: [GitCommit]) { + self.worktreeID = worktreeID + self.changes = changes + self.commits = commits + } +} + +package struct GitWorktree: Identifiable, Hashable, Sendable { + package let path: String + package let head: String + package let branch: String? + package let isCurrent: Bool + package let isPrimary: Bool + package let isBare: Bool + package let isDetached: Bool + package let isLocked: Bool + package let lockReason: String? + package let isPrunable: Bool + package let pruneReason: String? + + package init( + path: String, + head: String, + branch: String?, + isCurrent: Bool, + isPrimary: Bool, + isBare: Bool, + isDetached: Bool, + isLocked: Bool, + lockReason: String?, + isPrunable: Bool, + pruneReason: String? + ) { + self.path = path + self.head = head + self.branch = branch + self.isCurrent = isCurrent + self.isPrimary = isPrimary + self.isBare = isBare + self.isDetached = isDetached + self.isLocked = isLocked + self.lockReason = lockReason + self.isPrunable = isPrunable + self.pruneReason = pruneReason + } + + package var id: String { path } + package var url: URL { URL(fileURLWithPath: path) } + package var shortHead: String { String(head.prefix(8)) } + package var branchName: String? { + guard let branch else { return nil } + let prefix = "refs/heads/" + return branch.hasPrefix(prefix) ? String(branch.dropFirst(prefix.count)) : branch + } + package var displayName: String { + branchName ?? (isBare ? "Bare repository" : "Detached HEAD") + } +} + package enum GitReferenceKind: String, Sendable { case local case remote diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 9c93abf20..186b6728b 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -10,7 +10,7 @@ package protocol GitOperations: Sendable { func snapshot(at rootURL: URL) -> GitSnapshot? func watchContext(at rootURL: URL) -> GitWatchContext? - + func worktrees(at rootURL: URL) -> [GitWorktree]? func diffDocument( at rootURL: URL, @@ -81,6 +81,12 @@ package protocol GitOperations: Sendable { func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? + func createWorktree(named name: String, from reference: GitReference, at destination: URL, repositoryRoot: URL) -> GitProcessResult? + func removeWorktree(_ worktree: GitWorktree, force: Bool, at rootURL: URL) -> GitProcessResult? + func lockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? + func unlockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? + func repairWorktrees(at rootURL: URL) -> GitProcessResult? + func pruneWorktrees(at rootURL: URL) -> GitProcessResult? func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? @@ -177,6 +183,25 @@ package struct GitService: Sendable { await read(priority: .utility) { $0.snapshot(at: workspace) } } + func worktrees(at repositoryRoot: URL) async -> [GitWorktree]? { + await read(priority: .utility) { $0.worktrees(at: repositoryRoot) } + } + + func inspectWorktree( + _ worktree: GitWorktree, + reference: GitReference? + ) async -> GitWorktreeInspection? { + async let snapshot = snapshot(for: worktree.url) + async let history = history(at: worktree.url, reference: reference, limit: 50) + guard let snapshot = await snapshot else { return nil } + let resolvedHistory = await history + return GitWorktreeInspection( + worktreeID: worktree.id, + changes: snapshot.changes, + commits: resolvedHistory.commits + ) + } + func consoleVersion(at repositoryRoot: URL) async -> CommandResult { await command(at: repositoryRoot, fallbackArguments: ["version"]) { $0.run( @@ -494,6 +519,48 @@ package struct GitService: Sendable { } } + func createWorktree( + named name: String, + from reference: GitReference, + at destination: URL, + repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.createWorktree( + named: name, + from: reference, + at: destination, + repositoryRoot: repositoryRoot + ) + } + } + + func removeWorktree( + _ worktree: GitWorktree, + force: Bool, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.removeWorktree(worktree, force: force, at: repositoryRoot) + } + } + + func lockWorktree(_ worktree: GitWorktree, at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.lockWorktree(worktree, at: repositoryRoot) } + } + + func unlockWorktree(_ worktree: GitWorktree, at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.unlockWorktree(worktree, at: repositoryRoot) } + } + + func repairWorktrees(at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.repairWorktrees(at: repositoryRoot) } + } + + func pruneWorktrees(at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.pruneWorktrees(at: repositoryRoot) } + } + func renameBranch( _ reference: GitReference, to newName: String, From b592c8fe9b90d6884d1f44cb803c57a5ffa82313 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 21:55:07 +0800 Subject: [PATCH 03/28] feat(macos-ui): implement worktree management workspace --- .../Sources/Lithe/Views/Git/GitLogView.swift | 20 +- .../Lithe/Views/Git/GitWorktreesView.swift | 1018 +++++++++++++++++ 2 files changed, 1031 insertions(+), 7 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Git/GitWorktreesView.swift diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index fe5fe3c35..581385243 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -61,6 +61,7 @@ struct GitLogView: View { private enum GitToolTab { case log + case worktrees case console } @@ -79,6 +80,8 @@ struct GitLogView: View { detailPane: { detailPane } ) } + } else if selectedGitToolTab == .worktrees { + GitWorktreesView() } else { gitConsolePane } @@ -257,16 +260,19 @@ struct GitLogView: View { .log, title: "Log: \(model.selectedGitReference?.shortName ?? model.currentBranch)" ) + gitToolTabButton(.worktrees, title: "Worktrees") gitToolTabButton(.console, title: "Console") - Button { - selectedGitToolTab = .log - Task { await model.selectGitReference(nil) } - } label: { - Image(systemName: "plus") + if selectedGitToolTab == .log { + Button { + selectedGitToolTab = .log + Task { await model.selectGitReference(nil) } + } label: { + Image(systemName: "plus") + } + .litheIconButton() + .help("Show all references") } - .litheIconButton() - .help("Show all references") Menu { Button("Fetch All Remotes") { diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift new file mode 100644 index 000000000..1549e9ccb --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -0,0 +1,1018 @@ +import SwiftUI +import LitheGitModule + +struct GitWorktreesView: View { + private enum WorktreeSection: String, CaseIterable, Identifiable { + case overview = "Overview" + case changes = "Changes" + case history = "Commit History" + case settings = "Settings" + + var id: String { rawValue } + } + + private enum Visual { + static let title = Font.system(size: 18, weight: .semibold) + static let section = Font.system(size: 13, weight: .semibold) + static let body = Font.system(size: 13) + static let bodyMedium = Font.system(size: 13, weight: .medium) + static let metadata = Font.system(size: 12.5) + static let mono = Font.system(size: 12.5, design: .monospaced) + static let listWidth: CGFloat = 360 + static let quickInfoWidth: CGFloat = 282 + static let quickInfoThreshold: CGFloat = 1_080 + } + + private enum RemovalConfirmation: Identifiable { + case regular(GitWorktree) + case force(GitWorktree) + + var id: String { + switch self { + case .regular(let worktree): "regular:\(worktree.id)" + case .force(let worktree): "force:\(worktree.id)" + } + } + + var worktree: GitWorktree { + switch self { + case .regular(let worktree), .force(let worktree): worktree + } + } + } + + @EnvironmentObject private var model: AppModel + @State private var showsCreateSheet = false + @State private var removalConfirmation: RemovalConfirmation? + @State private var showsPruneConfirmation = false + @State private var searchText = "" + @State private var selectedWorktreeID: String? + @State private var activeSection = WorktreeSection.overview + + var body: some View { + GeometryReader { geometry in + HStack(spacing: 0) { + worktreeListPane + .frame(width: min(Visual.listWidth, max(286, geometry.size.width * 0.29))) + Divider() + worktreeDetailPane + if geometry.size.width >= Visual.quickInfoThreshold { + Divider() + quickInfoPane + .frame(width: Visual.quickInfoWidth) + } + } + } + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + .task(id: model.gitRepositoryRoot) { + await model.refreshGitWorktrees() + selectAvailableWorktree() + } + .task(id: selectedWorktreeID) { + guard let worktree = selectedWorktree, !worktree.isPrunable else { return } + await model.inspectGitWorktree(worktree) + } + .onChange(of: model.gitWorktrees.map(\.id)) { _ in + selectAvailableWorktree() + } + .sheet(isPresented: $showsCreateSheet) { + if let repositoryRoot = model.gitRepositoryRoot { + GitWorktreeCreateView( + repositoryRoot: repositoryRoot, + references: model.gitReferences, + currentReference: model.gitReferences.first(where: \.isCurrent) + ) { name, reference, destination in + Task { + await model.createGitWorktree( + named: name, + from: reference, + at: destination + ) + } + } + .environmentObject(model) + } + } + .alert(item: $removalConfirmation) { confirmation in + switch confirmation { + case .regular(let worktree): + Alert( + title: Text("Remove worktree?"), + message: Text(String( + format: String(localized: "Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning."), + worktree.displayName + )), + primaryButton: .destructive(Text("Remove")) { + Task { await model.removeGitWorktree(worktree, force: false) } + }, + secondaryButton: .default(Text("Review Force Remove…")) { + removalConfirmation = .force(worktree) + } + ) + case .force(let worktree): + Alert( + title: Text("Force remove worktree?"), + message: Text(String( + format: String(localized: "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept."), + worktree.displayName + )), + primaryButton: .destructive(Text("Force Remove")) { + Task { await model.removeGitWorktree(worktree, force: true) } + }, + secondaryButton: .cancel() + ) + } + } + .confirmationDialog( + "Prune stale worktree records?", + isPresented: $showsPruneConfirmation, + titleVisibility: .visible + ) { + Button("Prune Stale Records", role: .destructive) { + Task { await model.pruneGitWorktrees() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes Git registrations whose checkout directories no longer exist. It does not delete branches.") + } + } + + private var worktreeListPane: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Button { + showsCreateSheet = true + } label: { + Label("New Worktree", systemImage: "plus") + .font(Visual.bodyMedium) + .padding(.horizontal, 5) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .tint(LitheTheme.accent) + .lithePointer() + .disabled(model.gitReferences.isEmpty || model.isPerformingWorktreeOperation) + + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .foregroundStyle(LitheTheme.tertiaryText) + TextField("Search worktree or path", text: $searchText) + .textFieldStyle(.plain) + .font(Visual.body) + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(LitheTheme.inputBackground) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + } + .padding(.horizontal, 12) + .frame(height: 52) + + HStack { + Text("Worktrees") + .font(Visual.section) + Text(String(format: String(localized: "%lld worktrees"), filteredWorktrees.count)) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + Spacer() + if model.isPerformingWorktreeOperation || model.gitWorktreeLoadState == .loading { + ProgressView().controlSize(.small) + } + Button { + Task { await model.refreshGitWorktrees() } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .lithePointer() + .disabled(model.isPerformingWorktreeOperation) + .help("Refresh worktrees") + } + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 14) + .frame(height: 34) + + Divider() + + if filteredWorktrees.isEmpty { + listEmptyState + } else { + ScrollView { + LazyVStack(spacing: 7) { + ForEach(filteredWorktrees) { worktree in + worktreeListRow(worktree) + } + } + .padding(10) + } + .litheScrollViewChrome() + } + } + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) + } + + private func worktreeListRow(_ worktree: GitWorktree) -> some View { + let isSelected = selectedWorktree?.id == worktree.id + return Button { + selectedWorktreeID = worktree.id + activeSection = .overview + } label: { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 7) { + Text(worktree.isPrimary ? String(localized: "Main Worktree") : worktree.displayName) + .font(Visual.bodyMedium) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + if worktree.isPrimary { + Image(systemName: "crown.fill") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.warning) + } + if worktree.isCurrent { + worktreeBadge("Current", color: LitheTheme.accent) + } + Spacer(minLength: 6) + worktreeStatusLabel(worktree) + Image(systemName: "ellipsis") + .foregroundStyle(LitheTheme.secondaryText) + } + Text(worktree.path) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + Text(String( + format: String(localized: "Branch: %@"), + worktree.branchName ?? String(localized: "Detached HEAD") + )) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(isSelected ? LitheTheme.subtleSelection : LitheTheme.raised) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(isSelected ? LitheTheme.inputFocusBorder : LitheTheme.panelBorder, lineWidth: 1) + } + .lithePointer() + } + + @ViewBuilder + private var worktreeDetailPane: some View { + if let worktree = selectedWorktree { + VStack(spacing: 0) { + detailHeader(worktree) + detailTabs + Divider() + sectionContent(worktree) + if model.gitWorktrees.contains(where: \.isPrunable) { + staleWorktreeBanner + } + } + } else { + detailEmptyState + } + } + + private func detailHeader(_ worktree: GitWorktree) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 10) { + Text(worktree.isPrimary ? String(localized: "Main Worktree") : worktree.displayName) + .font(Visual.title) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + worktreeBadge("Worktree", color: LitheTheme.accent) + worktreeStatusLabel(worktree) + Spacer() + } + HStack(spacing: 8) { + Text(worktree.path) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Text("·") + .foregroundStyle(LitheTheme.tertiaryText) + Text(String( + format: String(localized: "Branch: %@"), + worktree.branchName ?? String(localized: "Detached HEAD") + )) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + Button { + model.copyProjectItemPath(worktree.url, relative: false) + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .lithePointer() + .help("Copy worktree path") + Spacer() + } + } + .padding(.horizontal, 18) + .frame(height: 74) + } + + private var detailTabs: some View { + HStack(spacing: 28) { + ForEach(WorktreeSection.allCases) { section in + detailTab(section) + } + Spacer() + } + .padding(.horizontal, 18) + .frame(height: 40) + } + + private func detailTab(_ section: WorktreeSection) -> some View { + Button { + activeSection = section + } label: { + Text(LocalizedStringKey(section.rawValue)) + .font(Visual.bodyMedium) + .foregroundStyle(activeSection == section ? LitheTheme.primaryText : LitheTheme.secondaryText) + .frame(height: 40) + .overlay(alignment: .bottom) { + Rectangle() + .fill(activeSection == section ? LitheTheme.tabUnderline : .clear) + .frame(height: 2) + } + } + .buttonStyle(.plain) + .lithePointer() + } + + @ViewBuilder + private func sectionContent(_ worktree: GitWorktree) -> some View { + ScrollView { + switch activeSection { + case .overview: + VStack(spacing: 12) { + HStack(alignment: .top, spacing: 12) { + basicInformationCard(worktree) + statusCard(worktree) + } + actionCard(worktree) + } + case .changes: + changesSection(worktree) + case .history: + historySection(worktree) + case .settings: + settingsSection(worktree) + } + } + .padding(14) + .litheScrollViewChrome() + } + + private func basicInformationCard(_ worktree: GitWorktree) -> some View { + worktreeCard(title: "Basic Information") { + informationRow("Path") { + HStack(spacing: 6) { + Text(worktree.path) + .lineLimit(1) + .truncationMode(.middle) + Button { + model.copyProjectItemPath(worktree.url, relative: false) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.plain) + .lithePointer() + } + } + informationRow("Branch", value: worktree.branchName ?? "Detached HEAD") + informationRow("HEAD") { + Text(worktree.shortHead) + .font(Visual.mono) + .foregroundStyle(LitheTheme.link) + .textSelection(.enabled) + } + informationRow("Role", value: worktree.isPrimary ? "Primary worktree" : "Linked worktree") + } + } + + private func statusCard(_ worktree: GitWorktree) -> some View { + worktreeCard(title: "Status") { + informationRow("Worktree") { + worktreeStatusLabel(worktree) + } + informationRow("Registration", value: worktree.isPrunable ? "Stale record" : "Valid") + informationRow("Protection", value: worktree.isLocked ? (worktree.lockReason ?? "Locked") : "Unlocked") + informationRow("Local changes", value: localChangesDescription(for: worktree)) + } + } + + private func actionCard(_ worktree: GitWorktree) -> some View { + worktreeCard(title: "Actions") { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], spacing: 10) { + worktreeAction("Copy Path", icon: "doc.on.doc") { + model.copyProjectItemPath(worktree.url, relative: false) + } + if worktree.isPrunable { + worktreeAction("Repair Worktree Records", icon: "wrench.and.screwdriver") { + Task { await model.repairGitWorktrees() } + } + worktreeAction("Prune Stale Records", icon: "trash.slash", destructive: true) { + showsPruneConfirmation = true + } + } else { + worktreeAction("Open in Lithe", icon: "macwindow") { + model.openProject(worktree.url) + } + worktreeAction("Show in Finder", icon: "folder") { + model.revealProjectItemInFinder(worktree.url) + } + worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { + Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } + } + .disabled(worktree.isPrimary) + .help(worktree.isPrimary ? String(localized: "The primary worktree cannot be locked.") : "") + worktreeAction("Remove Worktree…", icon: "trash", destructive: true) { + removalConfirmation = .regular(worktree) + } + .disabled(worktree.isPrimary || worktree.isCurrent || worktree.isLocked) + .help(removalHelp(for: worktree)) + worktreeAction("Prune Stale Records", icon: "trash.slash") { + showsPruneConfirmation = true + } + .disabled(!model.gitWorktrees.contains(where: \.isPrunable)) + } + } + } + } + + @ViewBuilder + private func changesSection(_ worktree: GitWorktree) -> some View { + if worktree.isPrunable { + missingPathState + } else if let inspection = matchingInspection(for: worktree) { + if inspection.changes.isEmpty { + worktreeMessage(icon: "checkmark.circle", title: "No local changes", detail: "This worktree has no uncommitted changes.") + } else { + worktreeCard(title: "Changes") { + VStack(spacing: 0) { + ForEach(inspection.changes) { change in + HStack(spacing: 10) { + Text(change.displayStatus) + .font(Visual.mono) + .foregroundStyle(changeColor(change)) + .frame(width: 22) + Text(change.path) + .font(Visual.body) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(LocalizedStringKey(change.isStaged ? "Staged" : "Unstaged")) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + } + .padding(.vertical, 9) + if change.id != inspection.changes.last?.id { Divider() } + } + } + } + } + } else { + inspectionState + } + } + + @ViewBuilder + private func historySection(_ worktree: GitWorktree) -> some View { + if worktree.isPrunable { + missingPathState + } else if let inspection = matchingInspection(for: worktree) { + if inspection.commits.isEmpty { + worktreeMessage(icon: "clock.arrow.circlepath", title: "No commits", detail: "No commits were found for this branch.") + } else { + worktreeCard(title: "Commit History") { + VStack(spacing: 0) { + ForEach(inspection.commits) { commit in + HStack(alignment: .top, spacing: 12) { + Image(systemName: "circle.fill") + .font(.system(size: 7)) + .foregroundStyle(LitheTheme.accent) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 4) { + Text(commit.subject) + .font(Visual.bodyMedium) + .foregroundStyle(LitheTheme.primaryText) + Text("\(commit.shortHash) · \(commit.authorName) · \(commit.date)") + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + Spacer() + } + .padding(.vertical, 9) + if commit.id != inspection.commits.last?.id { Divider() } + } + } + } + } + } else { + inspectionState + } + } + + private func settingsSection(_ worktree: GitWorktree) -> some View { + VStack(spacing: 12) { + worktreeCard(title: "Worktree Settings") { + informationRow("Protection", value: worktree.isLocked ? "Locked" : "Unlocked") + if !worktree.isPrunable { + worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { + Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } + } + .disabled(worktree.isPrimary) + } + } + worktreeCard(title: "Maintenance") { + if worktree.isPrunable { + worktreeAction("Repair Worktree Records", icon: "wrench.and.screwdriver") { + Task { await model.repairGitWorktrees() } + } + } + worktreeAction("Prune Stale Records", icon: "trash.slash") { + showsPruneConfirmation = true + } + .disabled(!model.gitWorktrees.contains(where: \.isPrunable)) + } + worktreeCard(title: "Danger Zone") { + worktreeAction("Remove Worktree…", icon: "trash", destructive: true) { + removalConfirmation = .regular(worktree) + } + .disabled(worktree.isPrimary || worktree.isCurrent || worktree.isLocked || worktree.isPrunable) + .help(removalHelp(for: worktree)) + } + } + } + + private func worktreeCard( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 11) { + Text(LocalizedStringKey(title)) + .font(Visual.section) + .foregroundStyle(LitheTheme.primaryText) + content() + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(LitheTheme.raised) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + } + + private func informationRow(_ label: String, value: String) -> some View { + informationRow(label) { + Text(LocalizedStringKey(value)) + .lineLimit(1) + .truncationMode(.middle) + } + } + + private func informationRow( + _ label: String, + @ViewBuilder content: () -> Content + ) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(LocalizedStringKey(label)) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 92, alignment: .leading) + content() + .font(Visual.body) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 0) + } + } + + private func worktreeAction( + _ title: String, + icon: String, + destructive: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label { + Text(LocalizedStringKey(title)) + } icon: { + Image(systemName: icon) + } + .font(Visual.body) + .foregroundStyle(destructive ? LitheTheme.error : LitheTheme.primaryText) + .frame(maxWidth: .infinity) + .frame(height: 30) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .lithePointer() + .disabled(model.isPerformingWorktreeOperation) + } + + private var staleWorktreeBanner: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(LitheTheme.warning) + VStack(alignment: .leading, spacing: 2) { + Text(String( + format: String(localized: "%lld worktree records need attention"), + model.gitWorktrees.filter(\.isPrunable).count + )) + .font(Visual.bodyMedium) + .foregroundStyle(LitheTheme.primaryText) + Text("A missing checkout can make the worktree list inaccurate.") + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + Button("Prune Stale Records") { showsPruneConfirmation = true } + .buttonStyle(.bordered) + .lithePointer() + } + .padding(.horizontal, 16) + .frame(minHeight: 58) + .background(LitheTheme.warning.opacity(0.10)) + .overlay(alignment: .top) { + Rectangle().fill(LitheTheme.warning.opacity(0.25)).frame(height: 1) + } + } + + private var quickInfoPane: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Quick Information") + .font(Visual.section) + Spacer() + Image(systemName: "pin") + .foregroundStyle(LitheTheme.secondaryText) + } + .padding(.horizontal, 16) + .frame(height: 52) + Divider() + if let worktree = selectedWorktree { + VStack(alignment: .leading, spacing: 18) { + quickInformationRow("Type", value: worktree.isPrimary ? "Primary" : "Worktree", accent: true) + quickInformationRow("Path", value: worktree.path) + quickInformationRow("Branch", value: worktree.branchName ?? "Detached HEAD") + quickInformationRow("HEAD", value: worktree.shortHead, accent: true, monospaced: true) + quickInformationRow("State", value: statusText(worktree)) + if let reason = worktree.lockReason ?? worktree.pruneReason { + quickInformationRow("Reason", value: reason) + } + Button { + model.copyProjectItemPath(worktree.url, relative: false) + } label: { + Label("Copy worktree path", systemImage: "arrow.right") + .font(Visual.bodyMedium) + } + .buttonStyle(.plain) + .foregroundStyle(LitheTheme.link) + .lithePointer() + } + .padding(16) + } + Spacer() + } + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) + } + + private func quickInformationRow( + _ label: String, + value: String, + accent: Bool = false, + monospaced: Bool = false + ) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(LocalizedStringKey(label)) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 62, alignment: .leading) + Text(LocalizedStringKey(value)) + .font(monospaced ? Visual.mono : Visual.body) + .foregroundStyle(accent ? LitheTheme.link : LitheTheme.primaryText) + .textSelection(.enabled) + Spacer(minLength: 0) + } + } + + @ViewBuilder + private func worktreeStatusLabel(_ worktree: GitWorktree) -> some View { + HStack(spacing: 5) { + Circle() + .fill(statusColor(worktree)) + .frame(width: 8, height: 8) + Text(LocalizedStringKey(statusText(worktree))) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.primaryText) + } + } + + private func worktreeBadge(_ title: String, color: Color) -> some View { + Text(LocalizedStringKey(title)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(color) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(color.opacity(0.11)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + private var filteredWorktrees: [GitWorktree] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return model.gitWorktrees } + return model.gitWorktrees.filter { + $0.displayName.localizedCaseInsensitiveContains(query) + || $0.path.localizedCaseInsensitiveContains(query) + || ($0.branchName?.localizedCaseInsensitiveContains(query) ?? false) + } + } + + private var selectedWorktree: GitWorktree? { + if let selectedWorktreeID, + let selected = model.gitWorktrees.first(where: { $0.id == selectedWorktreeID }) { + return selected + } + return model.gitWorktrees.first(where: \.isCurrent) ?? model.gitWorktrees.first + } + + private func selectAvailableWorktree() { + guard selectedWorktree == nil else { return } + selectedWorktreeID = model.gitWorktrees.first(where: \.isCurrent)?.id + ?? model.gitWorktrees.first?.id + } + + private func statusText(_ worktree: GitWorktree) -> String { + if worktree.isPrunable { return "Path Missing" } + if worktree.isLocked { return "Locked" } + if let inspection = matchingInspection(for: worktree), !inspection.changes.isEmpty { + return "Modified" + } + if worktree.isCurrent && !model.gitChanges.isEmpty { return "Modified" } + if worktree.isCurrent { return "Current" } + return "Available" + } + + private func statusColor(_ worktree: GitWorktree) -> Color { + if worktree.isPrunable { return LitheTheme.error } + if worktree.isLocked { return LitheTheme.warning } + if let inspection = matchingInspection(for: worktree), !inspection.changes.isEmpty { + return LitheTheme.warning + } + if worktree.isCurrent && !model.gitChanges.isEmpty { return LitheTheme.warning } + return LitheTheme.success + } + + private func localChangesDescription(for worktree: GitWorktree) -> String { + let count: Int + if let inspection = matchingInspection(for: worktree) { + count = inspection.changes.count + } else if worktree.isCurrent { + count = model.gitChanges.count + } else { + return "Loading…" + } + if count == 0 { return String(localized: "No changes") } + return String(format: String(localized: "%lld changed files"), count) + } + + private func matchingInspection(for worktree: GitWorktree) -> GitWorktreeInspection? { + guard model.gitWorktreeInspection?.worktreeID == worktree.id else { return nil } + return model.gitWorktreeInspection + } + + @ViewBuilder + private var inspectionState: some View { + switch model.gitWorktreeInspectionLoadState { + case .idle, .loading: + worktreeMessage(icon: "arrow.clockwise", title: "Loading worktree details", detail: "Reading changes and recent commits.") + case .failed(let message): + worktreeMessage(icon: "exclamationmark.triangle", title: "Could not inspect this worktree", detail: message) + case .ready: + worktreeMessage(icon: "arrow.clockwise", title: "Loading worktree details", detail: "Reading changes and recent commits.") + } + } + + private var missingPathState: some View { + worktreeCard(title: "Checkout Path Missing") { + Text("The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record.") + .font(Visual.body) + .foregroundStyle(LitheTheme.secondaryText) + HStack(spacing: 10) { + worktreeAction("Repair Worktree Records", icon: "wrench.and.screwdriver") { + Task { await model.repairGitWorktrees() } + } + worktreeAction("Prune Stale Records", icon: "trash.slash", destructive: true) { + showsPruneConfirmation = true + } + } + } + } + + private func removalHelp(for worktree: GitWorktree) -> String { + if worktree.isPrimary { return String(localized: "The primary worktree cannot be removed.") } + if worktree.isCurrent { return String(localized: "The current worktree cannot be removed here.") } + if worktree.isLocked { return String(localized: "Unlock the worktree before removing it.") } + if worktree.isPrunable { return String(localized: "Prune the stale record instead.") } + return "" + } + + private func changeColor(_ change: GitChange) -> Color { + switch change.kind { + case .added: LitheTheme.success + case .deleted, .conflicted: LitheTheme.error + case .modified, .moved, .copied: LitheTheme.warning + } + } + + @ViewBuilder + private var listEmptyState: some View { + switch model.gitWorktreeLoadState { + case .idle, .loading: + worktreeMessage(icon: "arrow.clockwise", title: "Loading worktrees", detail: "Reading registered checkouts.") + case .failed(let message): + worktreeMessage(icon: "exclamationmark.triangle", title: "Worktrees are unavailable", detail: message) + case .ready where !searchText.isEmpty: + worktreeMessage(icon: "magnifyingglass", title: "No matches", detail: "Try a branch name or checkout path.") + case .ready: + worktreeMessage(icon: "point.3.connected.trianglepath.dotted", title: "No worktrees", detail: "Create a checkout to get started.") + } + } + + private var detailEmptyState: some View { + worktreeMessage(icon: "point.3.connected.trianglepath.dotted", title: "Select a worktree", detail: "Its details and actions will appear here.") + } + + private func worktreeMessage(icon: String, title: String, detail: String) -> some View { + VStack(spacing: 9) { + Image(systemName: icon) + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(LitheTheme.secondaryText) + Text(LocalizedStringKey(title)) + .font(Visual.section) + .foregroundStyle(LitheTheme.primaryText) + Text(LocalizedStringKey(detail)) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct GitWorktreeCreateView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + let repositoryRoot: URL + let references: [GitReference] + let currentReference: GitReference? + let onSubmit: (String, GitReference, URL) -> Void + + @State private var branchName = "" + @State private var selectedReferenceID = "" + @State private var destinationPath = "" + @State private var destinationWasEdited = false + @FocusState private var branchFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text("New Worktree") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text("Create an independent checkout and a new branch from the selected reference.") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Start from") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + Picker("Start from", selection: $selectedReferenceID) { + ForEach(references) { reference in + Text(reference.shortName).tag(reference.id) + } + } + .labelsHidden() + } + + VStack(alignment: .leading, spacing: 6) { + Text("New branch") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("feature/my-task", text: $branchName) + .textFieldStyle(.roundedBorder) + .focused($branchFieldFocused) + .onChange(of: branchName) { _ in updateSuggestedDestination() } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Checkout path") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + HStack(spacing: 8) { + TextField("Worktree destination", text: destinationBinding) + .textFieldStyle(.roundedBorder) + Button("Choose Parent…") { + guard let parent = model.chooseGitWorktreeParentDirectory() else { return } + destinationWasEdited = true + destinationPath = parent.appendingPathComponent(suggestedDirectoryName).path + } + .lithePointer() + } + Text("Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + .lithePointer() + Button("Create") { + guard let selectedReference else { return } + onSubmit(trimmedBranchName, selectedReference, URL(fileURLWithPath: destinationPath)) + dismiss() + } + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .keyboardShortcut(.defaultAction) + .lithePointer() + .disabled(trimmedBranchName.isEmpty || destinationPath.isEmpty || selectedReference == nil) + } + } + .padding(20) + .frame(width: 520) + .background(LitheTheme.raised) + .onAppear { + selectedReferenceID = currentReference?.id ?? references.first?.id ?? "" + updateSuggestedDestination(force: true) + branchFieldFocused = true + } + } + + private var selectedReference: GitReference? { + references.first(where: { $0.id == selectedReferenceID }) + } + + private var destinationBinding: Binding { + Binding( + get: { destinationPath }, + set: { + destinationPath = $0 + destinationWasEdited = true + } + ) + } + + private var trimmedBranchName: String { + branchName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var suggestedDirectoryName: String { + let leaf = trimmedBranchName + .split(separator: "/") + .last + .map(String.init) ?? "worktree" + let safeLeaf = leaf.map { character in + character.isLetter || character.isNumber || character == "-" || character == "_" + ? character + : "-" + } + return "\(repositoryRoot.lastPathComponent)-\(String(safeLeaf))" + } + + private func updateSuggestedDestination(force: Bool = false) { + guard force || !destinationWasEdited else { return } + destinationPath = repositoryRoot + .deletingLastPathComponent() + .appendingPathComponent(suggestedDirectoryName) + .path + destinationWasEdited = false + } +} From 8e5fa9e3a41a25c96435c675cf17f010460ba436 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 21:55:17 +0800 Subject: [PATCH 04/28] test(macos): cover worktree localization and lifecycle --- macos/Resources/en.lproj/Localizable.strings | 104 +++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 92 +++++++++++++ .../LitheGitModuleTests/GitModuleTests.swift | 123 ++++++++++++++++++ .../LitheTests/AppLocalizationTests.swift | 21 +++ 4 files changed, 340 insertions(+) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 63afb8db4..03bfc9837 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -128,3 +128,107 @@ "Retry debugging" = "Retry debugging"; "Pull Requests integration is under development" = "Pull Requests integration is under development"; "GitHub sign-in and pull request management are temporarily unavailable." = "GitHub sign-in and pull request management are temporarily unavailable."; + +/* Git worktree workbench. */ +"Worktrees" = "Worktrees"; +"New Worktree" = "New Worktree"; +"Search worktree or path" = "Search worktree or path"; +"Refresh worktrees" = "Refresh worktrees"; +"Main Worktree" = "Main Worktree"; +"Current" = "Current"; +"Branch: %@" = "Branch: %@"; +"Detached HEAD" = "Detached HEAD"; +"Worktree" = "Worktree"; +"Copy worktree path" = "Copy worktree path"; +"Commit History" = "Commit History"; +"Settings" = "Settings"; +"Basic Information" = "Basic Information"; +"Path" = "Path"; +"Branch" = "Branch"; +"HEAD" = "HEAD"; +"Role" = "Role"; +"Primary worktree" = "Primary worktree"; +"Linked worktree" = "Linked worktree"; +"Status" = "Status"; +"Registration" = "Registration"; +"Stale record" = "Stale record"; +"Valid" = "Valid"; +"Protection" = "Protection"; +"Locked" = "Locked"; +"Unlocked" = "Unlocked"; +"Local changes" = "Local changes"; +"Actions" = "Actions"; +"Open in Lithe" = "Open in Lithe"; +"Show in Finder" = "Show in Finder"; +"Copy Path" = "Copy Path"; +"Unlock Worktree" = "Unlock Worktree"; +"Lock Worktree" = "Lock Worktree"; +"Remove Worktree…" = "Remove Worktree…"; +"Prune Stale Records" = "Prune Stale Records"; +"%lld worktree records need attention" = "%lld worktree records need attention"; +"A missing checkout can make the worktree list inaccurate." = "A missing checkout can make the worktree list inaccurate."; +"Quick Information" = "Quick Information"; +"Primary" = "Primary"; +"State" = "State"; +"Reason" = "Reason"; +"Path Missing" = "Path Missing"; +"Modified" = "Modified"; +"Available" = "Available"; +"Open to inspect" = "Open to inspect"; +"No changes" = "No changes"; +"%lld changed files" = "%lld changed files"; +"Loading worktrees" = "Loading worktrees"; +"Reading registered checkouts." = "Reading registered checkouts."; +"Worktrees are unavailable" = "Worktrees are unavailable"; +"No matches" = "No matches"; +"Try a branch name or checkout path." = "Try a branch name or checkout path."; +"No worktrees" = "No worktrees"; +"Create a checkout to get started." = "Create a checkout to get started."; +"Select a worktree" = "Select a worktree"; +"Its details and actions will appear here." = "Its details and actions will appear here."; +"Remove worktree?" = "Remove worktree?"; +"Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning." = "Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning."; +"Remove" = "Remove"; +"Review Force Remove…" = "Review Force Remove…"; +"Force remove worktree?" = "Force remove worktree?"; +"This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept."; +"Force Remove" = "Force Remove"; +"Prune stale worktree records?" = "Prune stale worktree records?"; +"This removes Git registrations whose checkout directories no longer exist. It does not delete branches." = "This removes Git registrations whose checkout directories no longer exist. It does not delete branches."; +"Create an independent checkout and a new branch from the selected reference." = "Create an independent checkout and a new branch from the selected reference."; +"Start from" = "Start from"; +"New branch" = "New branch"; +"Checkout path" = "Checkout path"; +"Worktree destination" = "Worktree destination"; +"Choose Parent…" = "Choose Parent…"; +"Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts." = "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts."; +"Create" = "Create"; +"Enter a branch name" = "Enter a branch name"; +"Created worktree for %@" = "Created worktree for %@"; +"Removed %@" = "Removed %@"; +"Locked %@" = "Locked %@"; +"Unlocked %@" = "Unlocked %@"; +"Pruned stale worktree records" = "Pruned stale worktree records"; +"Repair Worktree Records" = "Repair Worktree Records"; +"Repaired worktree records" = "Repaired worktree records"; +"The checkout path does not exist" = "The checkout path does not exist"; +"Could not inspect this worktree" = "Could not inspect this worktree"; +"Loading worktree details" = "Loading worktree details"; +"Reading changes and recent commits." = "Reading changes and recent commits."; +"No local changes" = "No local changes"; +"This worktree has no uncommitted changes." = "This worktree has no uncommitted changes."; +"No commits" = "No commits"; +"No commits were found for this branch." = "No commits were found for this branch."; +"Staged" = "Staged"; +"Unstaged" = "Unstaged"; +"Worktree Settings" = "Worktree Settings"; +"Maintenance" = "Maintenance"; +"Danger Zone" = "Danger Zone"; +"Checkout Path Missing" = "Checkout Path Missing"; +"The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record." = "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record."; +"The primary worktree cannot be locked." = "The primary worktree cannot be locked."; +"The primary worktree cannot be removed." = "The primary worktree cannot be removed."; +"The current worktree cannot be removed here." = "The current worktree cannot be removed here."; +"Unlock the worktree before removing it." = "Unlock the worktree before removing it."; +"Prune the stale record instead." = "Prune the stale record instead."; +"Loading…" = "Loading…"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 34e576d97..45fea7430 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1300,3 +1300,95 @@ "Console" = "控制台"; "Debugger" = "调试器"; "Breakpoints" = "断点"; + +/* Git 工作树管理台。 */ +"Worktrees" = "工作树"; +"New Worktree" = "新建工作树"; +"Search worktree or path" = "搜索工作树或路径"; +"Refresh worktrees" = "刷新工作树"; +"Main Worktree" = "主工作区(主)"; +"Branch: %@" = "分支:%@"; +"Detached HEAD" = "分离 HEAD"; +"Worktree" = "工作树"; +"Copy worktree path" = "复制工作树路径"; +"Commit History" = "提交历史"; +"Basic Information" = "基本信息"; +"Path" = "路径"; +"Branch" = "分支"; +"HEAD" = "HEAD"; +"Role" = "类型"; +"Primary worktree" = "主工作区"; +"Linked worktree" = "关联工作树"; +"Registration" = "注册状态"; +"Stale record" = "陈旧记录"; +"Valid" = "有效"; +"Protection" = "保护状态"; +"Locked" = "已锁定"; +"Unlocked" = "未锁定"; +"Local changes" = "本地更改"; +"Open in Lithe" = "在 Lithe 中打开"; +"Unlock Worktree" = "解锁工作树"; +"Lock Worktree" = "锁定工作树"; +"Remove Worktree…" = "删除工作树…"; +"Prune Stale Records" = "清理陈旧记录"; +"%lld worktree records need attention" = "%lld 个工作树需要修复"; +"A missing checkout can make the worktree list inaccurate." = "路径不存在或无效的工作树会影响列表的准确性。"; +"Quick Information" = "快速信息"; +"Primary" = "主工作区"; +"Reason" = "原因"; +"Path Missing" = "路径不存在"; +"Available" = "可用"; +"Open to inspect" = "打开后检查"; +"No changes" = "没有更改"; +"%lld changed files" = "%lld 个文件有更改"; +"Loading worktrees" = "正在加载工作树"; +"Reading registered checkouts." = "正在读取 Git 中注册的检出目录。"; +"Worktrees are unavailable" = "无法加载工作树"; +"Try a branch name or checkout path." = "请尝试输入分支名称或检出路径。"; +"No worktrees" = "没有工作树"; +"Create a checkout to get started." = "新建一个检出目录以开始使用。"; +"Select a worktree" = "选择工作树"; +"Its details and actions will appear here." = "工作树详情和可用操作会显示在这里。"; +"Remove worktree?" = "删除工作树?"; +"Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning." = "删除“%@”及其检出目录?对应分支会保留。如果 Git 因文件已更改而拒绝删除,你可以继续查看单独的强制删除警告。"; +"Review Force Remove…" = "查看强制删除警告…"; +"Force remove worktree?" = "强制删除工作树?"; +"This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "这会永久删除“%@”中的未提交文件和未跟踪文件,但会保留对应分支。"; +"Force Remove" = "强制删除"; +"Prune stale worktree records?" = "清理陈旧的工作树记录?"; +"This removes Git registrations whose checkout directories no longer exist. It does not delete branches." = "这会移除检出目录已不存在的 Git 注册记录,但不会删除分支。"; +"Create an independent checkout and a new branch from the selected reference." = "从所选引用创建独立检出目录和新分支。"; +"Start from" = "起始引用"; +"New branch" = "新分支"; +"Checkout path" = "检出路径"; +"Worktree destination" = "工作树目标路径"; +"Choose Parent…" = "选择父目录…"; +"Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts." = "建议将工作树放在仓库旁的持久目录中。临时检出时可以手动选择 /private/tmp。"; +"Created worktree for %@" = "已为 %@ 创建工作树"; +"Removed %@" = "已删除 %@"; +"Locked %@" = "已锁定 %@"; +"Unlocked %@" = "已解锁 %@"; +"Pruned stale worktree records" = "已清理陈旧的工作树记录"; +"Repair Worktree Records" = "修复工作树记录"; +"Repaired worktree records" = "已修复工作树记录"; +"The checkout path does not exist" = "检出路径不存在"; +"Could not inspect this worktree" = "无法检查此工作树"; +"Loading worktree details" = "正在加载工作树详情"; +"Reading changes and recent commits." = "正在读取文件更改和最近提交。"; +"No local changes" = "没有本地更改"; +"This worktree has no uncommitted changes." = "此工作树没有未提交的更改。"; +"No commits" = "没有提交记录"; +"No commits were found for this branch." = "此分支没有找到提交记录。"; +"Staged" = "已暂存"; +"Unstaged" = "未暂存"; +"Worktree Settings" = "工作树设置"; +"Maintenance" = "维护"; +"Danger Zone" = "危险操作"; +"Checkout Path Missing" = "检出路径不存在"; +"The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record." = "检出目录已不存在。你可以修复移动后的工作树元数据,或清理失效的 Git 记录。"; +"The primary worktree cannot be locked." = "主工作区不能锁定。"; +"The primary worktree cannot be removed." = "主工作区不能删除。"; +"The current worktree cannot be removed here." = "不能在这里删除当前工作树。"; +"Unlock the worktree before removing it." = "请先解锁工作树再删除。"; +"Prune the stale record instead." = "请改用清理陈旧记录。"; +"Loading…" = "正在加载…"; diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 6ca0bbfc6..3fd92f074 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -912,6 +912,46 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.first?.workingDirectory == secondRoot) } + @Test + func newerWorktreeRefreshWinsWhenAnOlderRequestFinishesLast() async throws { + let root = URL(fileURLWithPath: "/workspace") + let oldWorktree = makeTestWorktree(path: "/workspace-old", branch: "feature/old") + let newWorktree = makeTestWorktree(path: "/workspace-new", branch: "feature/new") + let loader = GitWorktreeLoadController(results: [[oldWorktree], [newWorktree]]) + let feature = GitFeatureModel( + service: GitService(operations: TestGitOperations()), + snapshotProvider: { root in + GitSnapshot(repositoryRoot: root, branch: "main", changes: []) + }, + worktreesProvider: { root in await loader.load(root) } + ) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + await feature.refreshGit() + + let firstRefresh = Task { @MainActor in await feature.refreshWorktrees() } + try #require(await loader.waitUntilCallStarts(0)) + let secondRefresh = Task { @MainActor in await feature.refreshWorktrees() } + defer { + firstRefresh.cancel() + secondRefresh.cancel() + loader.releaseAll() + } + try #require(await loader.waitUntilCallStarts(1)) + loader.releaseCall(1) + try #require(await waitForGitTaskCompletion(secondRefresh)) + loader.releaseCall(0) + try #require(await waitForGitTaskCompletion(firstRefresh)) + + #expect(!loader.didTimeOut) + #expect(feature.gitWorktreeLoadState == .ready) + #expect(feature.gitWorktrees == [newWorktree]) + } + @Test func gitConsolePreservesStandardErrorColorForSuccessfulCommands() { let entry = GitConsoleEntry( @@ -1226,6 +1266,22 @@ private func makeTestCommit(hash: String, subject: String) -> GitCommit { ) } +private func makeTestWorktree(path: String, branch: String) -> GitWorktree { + GitWorktree( + path: path, + head: "1111111111111111111111111111111111111111", + branch: "refs/heads/\(branch)", + isCurrent: false, + isPrimary: false, + isBare: false, + isDetached: false, + isLocked: false, + lockReason: nil, + isPrunable: false, + pruneReason: nil + ) +} + private final class GitModuleTestGate: @unchecked Sendable { private let condition = NSCondition() private var isOpen = false @@ -1301,6 +1357,66 @@ private final class GitModuleTestGate: @unchecked Sendable { } } +private final class GitWorktreeLoadController: @unchecked Sendable { + private let lock = NSLock() + private let results: [[GitWorktree]?] + private let startedGates: [GitModuleTestGate] + private let releaseGates: [GitModuleTestGate] + private var calls = 0 + private var timedOut = false + + init(results: [[GitWorktree]?]) { + self.results = results + startedGates = results.map { _ in GitModuleTestGate() } + releaseGates = results.map { _ in GitModuleTestGate() } + } + + var didTimeOut: Bool { + lock.lock() + defer { lock.unlock() } + return timedOut + } + + func load(_ root: URL) async -> [GitWorktree]? { + let callIndex = reserveCall() + guard results.indices.contains(callIndex) else { return nil } + startedGates[callIndex].open() + guard await releaseGates[callIndex].waitUntilOpen() else { + recordTimeout() + return nil + } + return results[callIndex] + } + + func waitUntilCallStarts(_ index: Int) async -> Bool { + guard startedGates.indices.contains(index) else { return false } + return await startedGates[index].waitUntilOpen() + } + + func releaseCall(_ index: Int) { + guard releaseGates.indices.contains(index) else { return } + releaseGates[index].open() + } + + func releaseAll() { + releaseGates.forEach { $0.open() } + } + + private func reserveCall() -> Int { + lock.lock() + defer { lock.unlock() } + let callIndex = calls + calls += 1 + return callIndex + } + + private func recordTimeout() { + lock.lock() + timedOut = true + lock.unlock() + } +} + private final class GitFilesLoadGate: @unchecked Sendable { private let lock = NSLock() private let results: [[GitCommitFile]?] @@ -1511,6 +1627,7 @@ private struct TestGitOperations: GitOperations { func snapshot(at rootURL: URL) -> GitSnapshot? { snapshotValue } func watchContext(at rootURL: URL) -> GitWatchContext? { nil } + func worktrees(at rootURL: URL) -> [GitWorktree]? { nil } func diffDocument(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> DiffDocument? { untracked ? untrackedDiffDocumentValue : nil } @@ -1541,6 +1658,12 @@ private struct TestGitOperations: GitOperations { func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { nil } + func createWorktree(named name: String, from reference: GitReference, at destination: URL, repositoryRoot: URL) -> GitProcessResult? { nil } + func removeWorktree(_ worktree: GitWorktree, force: Bool, at rootURL: URL) -> GitProcessResult? { nil } + func lockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? { nil } + func unlockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? { nil } + func repairWorktrees(at rootURL: URL) -> GitProcessResult? { nil } + func pruneWorktrees(at rootURL: URL) -> GitProcessResult? { nil } func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { nil } func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index f4fd18559..fd365885c 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -115,6 +115,27 @@ struct AppLocalizationTests { ) } + @Test + func simplifiedChineseResourcesCoverGitWorktreeWorkbench() throws { + let translations = try simplifiedChineseTranslations() + let expected = [ + "Worktrees": "工作树", + "New Worktree": "新建工作树", + "Commit History": "提交历史", + "Repair Worktree Records": "修复工作树记录", + "Prune Stale Records": "清理陈旧记录", + "No local changes": "没有本地更改", + "Worktree Settings": "工作树设置", + "Danger Zone": "危险操作", + "Checkout Path Missing": "检出路径不存在" + ,"Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.": "建议将工作树放在仓库旁的持久目录中。临时检出时可以手动选择 /private/tmp。" + ] + + for (key, value) in expected { + #expect(translations[key] == value, "Missing or incorrect worktree translation: \(key)") + } + } + @Test func simplifiedChineseResourcesCoverKeymapControls() throws { let translations = try simplifiedChineseTranslations() From d01f31c1feb6dab74646917110d3fe4b5dfacbe4 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:05:39 +0800 Subject: [PATCH 05/28] feat(git): create worktrees from a specific revision --- .../Sources/Lithe/Core/Rust/RustGitOperations.swift | 2 ++ .../Models/AppModel/AppModel+GitOperations.swift | 3 ++- .../Application/GitFeatureModel.swift | 2 ++ .../LitheGitModule/Services/GitService.swift | 6 ++++-- rust/lithe-core/src/git/mod.rs | 13 ++++++------- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 26c76a9eb..29e62eaa7 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -148,6 +148,7 @@ struct RustGitOperations: GitOperations, Sendable { func createWorktree( named name: String, from reference: GitReference, + revision: String? = nil, at destination: URL, repositoryRoot: URL ) -> GitProcessResult? { @@ -155,6 +156,7 @@ struct RustGitOperations: GitOperations, Sendable { at: repositoryRoot, operation: "createWorktree", gitReference: reference, + revision: revision, name: name, destination: destination ) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift index 85752db55..5ddc0bd77 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift @@ -149,10 +149,11 @@ extension AppModel { func createGitWorktree( named name: String, from reference: GitReference, + revision: String? = nil, at destination: URL ) async { guard let gitFeature = await activateGitModule() else { return } - await gitFeature.createWorktree(named: name, from: reference, at: destination) + await gitFeature.createWorktree(named: name, from: reference, revision: revision, at: destination) } func removeGitWorktree(_ worktree: GitWorktree, force: Bool) async { diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 371e1043d..49b3be361 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1669,6 +1669,7 @@ package final class GitFeatureModel: ObservableObject { package func createWorktree( named rawName: String, from reference: GitReference, + revision: String? = nil, at destination: URL ) async { guard let gitRepositoryRoot, !isPerformingWorktreeOperation else { return } @@ -1682,6 +1683,7 @@ package final class GitFeatureModel: ObservableObject { await service.createWorktree( named: name, from: reference, + revision: revision?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true ? nil : revision, at: destination.standardizedFileURL, repositoryRoot: gitRepositoryRoot ) diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 186b6728b..2fec6811f 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -81,7 +81,7 @@ package protocol GitOperations: Sendable { func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? - func createWorktree(named name: String, from reference: GitReference, at destination: URL, repositoryRoot: URL) -> GitProcessResult? + func createWorktree(named name: String, from reference: GitReference, revision: String?, at destination: URL, repositoryRoot: URL) -> GitProcessResult? func removeWorktree(_ worktree: GitWorktree, force: Bool, at rootURL: URL) -> GitProcessResult? func lockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? func unlockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? @@ -192,7 +192,7 @@ package struct GitService: Sendable { reference: GitReference? ) async -> GitWorktreeInspection? { async let snapshot = snapshot(for: worktree.url) - async let history = history(at: worktree.url, reference: reference, limit: 50) + async let history = history(at: worktree.url, reference: reference, limit: 300) guard let snapshot = await snapshot else { return nil } let resolvedHistory = await history return GitWorktreeInspection( @@ -522,6 +522,7 @@ package struct GitService: Sendable { func createWorktree( named name: String, from reference: GitReference, + revision: String? = nil, at destination: URL, repositoryRoot: URL ) async -> CommandResult { @@ -529,6 +530,7 @@ package struct GitService: Sendable { $0.createWorktree( named: name, from: reference, + revision: revision, at: destination, repositoryRoot: repositoryRoot ) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index ce5e44113..e664de09b 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -1584,6 +1584,11 @@ fn read_commit_log( ) -> Result<(Vec, bool), CoreError> { let mut arguments = vec!["log".to_string()]; arguments.extend(selectors); + let source = if let Some(revision) = request.revision.as_deref() { + validated_revision(Some(revision))? + } else { + reference.full_name.clone() + }; arguments.extend([ "--topo-order".to_string(), "--decorate=short".to_string(), @@ -4406,13 +4411,7 @@ fn create_worktree(root: &str, request: &GitWriteRequest) -> Result Date: Wed, 2 Sep 2026 22:05:50 +0800 Subject: [PATCH 06/28] fix(macos-ui): show worktree history and revision controls --- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Lithe/Views/Git/GitWorktreesView.swift | 88 ++++++++++++------- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 03bfc9837..0b9444d6d 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -141,6 +141,7 @@ "Worktree" = "Worktree"; "Copy worktree path" = "Copy worktree path"; "Commit History" = "Commit History"; +"Commit History (%lld)" = "Commit History (%lld)"; "Settings" = "Settings"; "Basic Information" = "Basic Information"; "Path" = "Path"; @@ -227,6 +228,7 @@ "Checkout Path Missing" = "Checkout Path Missing"; "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record." = "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record."; "The primary worktree cannot be locked." = "The primary worktree cannot be locked."; +"Repair or prune the missing checkout before changing its lock." = "Repair or prune the missing checkout before changing its lock."; "The primary worktree cannot be removed." = "The primary worktree cannot be removed."; "The current worktree cannot be removed here." = "The current worktree cannot be removed here."; "Unlock the worktree before removing it." = "Unlock the worktree before removing it."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 45fea7430..89f4c2958 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1312,6 +1312,7 @@ "Worktree" = "工作树"; "Copy worktree path" = "复制工作树路径"; "Commit History" = "提交历史"; +"Commit History (%lld)" = "提交历史(%lld 条)"; "Basic Information" = "基本信息"; "Path" = "路径"; "Branch" = "分支"; @@ -1387,6 +1388,7 @@ "Checkout Path Missing" = "检出路径不存在"; "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record." = "检出目录已不存在。你可以修复移动后的工作树元数据,或清理失效的 Git 记录。"; "The primary worktree cannot be locked." = "主工作区不能锁定。"; +"Repair or prune the missing checkout before changing its lock." = "请先修复或清理路径缺失的工作树,再更改锁定状态。"; "The primary worktree cannot be removed." = "主工作区不能删除。"; "The current worktree cannot be removed here." = "不能在这里删除当前工作树。"; "Unlock the worktree before removing it." = "请先解锁工作树再删除。"; diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index 1549e9ccb..db93d3b83 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -81,11 +81,12 @@ struct GitWorktreesView: View { repositoryRoot: repositoryRoot, references: model.gitReferences, currentReference: model.gitReferences.first(where: \.isCurrent) - ) { name, reference, destination in + ) { name, reference, revision, destination in Task { await model.createGitWorktree( named: name, from: reference, + revision: revision, at: destination ) } @@ -422,38 +423,38 @@ struct GitWorktreesView: View { private func actionCard(_ worktree: GitWorktree) -> some View { worktreeCard(title: "Actions") { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], spacing: 10) { + worktreeAction("Open in Lithe", icon: "macwindow") { + model.openProject(worktree.url) + } + .disabled(worktree.isPrunable) + .help(pathActionHelp(for: worktree)) + worktreeAction("Show in Finder", icon: "folder") { + model.revealProjectItemInFinder(worktree.url) + } + .disabled(worktree.isPrunable) + .help(pathActionHelp(for: worktree)) worktreeAction("Copy Path", icon: "doc.on.doc") { model.copyProjectItemPath(worktree.url, relative: false) } + worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { + Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } + } + .disabled(worktree.isPrimary || worktree.isPrunable) + .help(lockHelp(for: worktree)) + worktreeAction("Remove Worktree…", icon: "trash", destructive: true) { + removalConfirmation = .regular(worktree) + } + .disabled(worktree.isPrimary || worktree.isCurrent || worktree.isLocked || worktree.isPrunable) + .help(removalHelp(for: worktree)) if worktree.isPrunable { worktreeAction("Repair Worktree Records", icon: "wrench.and.screwdriver") { Task { await model.repairGitWorktrees() } } - worktreeAction("Prune Stale Records", icon: "trash.slash", destructive: true) { - showsPruneConfirmation = true - } - } else { - worktreeAction("Open in Lithe", icon: "macwindow") { - model.openProject(worktree.url) - } - worktreeAction("Show in Finder", icon: "folder") { - model.revealProjectItemInFinder(worktree.url) - } - worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { - Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } - } - .disabled(worktree.isPrimary) - .help(worktree.isPrimary ? String(localized: "The primary worktree cannot be locked.") : "") - worktreeAction("Remove Worktree…", icon: "trash", destructive: true) { - removalConfirmation = .regular(worktree) - } - .disabled(worktree.isPrimary || worktree.isCurrent || worktree.isLocked) - .help(removalHelp(for: worktree)) - worktreeAction("Prune Stale Records", icon: "trash.slash") { - showsPruneConfirmation = true - } - .disabled(!model.gitWorktrees.contains(where: \.isPrunable)) } + worktreeAction("Prune Stale Records", icon: "trash.slash", destructive: worktree.isPrunable) { + showsPruneConfirmation = true + } + .disabled(!model.gitWorktrees.contains(where: \.isPrunable)) } } } @@ -502,7 +503,7 @@ struct GitWorktreesView: View { if inspection.commits.isEmpty { worktreeMessage(icon: "clock.arrow.circlepath", title: "No commits", detail: "No commits were found for this branch.") } else { - worktreeCard(title: "Commit History") { + worktreeCard(title: String(format: String(localized: "Commit History (%lld)"), inspection.commits.count)) { VStack(spacing: 0) { ForEach(inspection.commits) { commit in HStack(alignment: .top, spacing: 12) { @@ -536,12 +537,11 @@ struct GitWorktreesView: View { VStack(spacing: 12) { worktreeCard(title: "Worktree Settings") { informationRow("Protection", value: worktree.isLocked ? "Locked" : "Unlocked") - if !worktree.isPrunable { - worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { - Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } - } - .disabled(worktree.isPrimary) + worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { + Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } } + .disabled(worktree.isPrimary || worktree.isPrunable) + .help(lockHelp(for: worktree)) } worktreeCard(title: "Maintenance") { if worktree.isPrunable { @@ -838,6 +838,16 @@ struct GitWorktreesView: View { return "" } + private func lockHelp(for worktree: GitWorktree) -> String { + if worktree.isPrimary { return String(localized: "The primary worktree cannot be locked.") } + if worktree.isPrunable { return String(localized: "Repair or prune the missing checkout before changing its lock.") } + return "" + } + + private func pathActionHelp(for worktree: GitWorktree) -> String { + worktree.isPrunable ? String(localized: "The checkout path does not exist") : "" + } + private func changeColor(_ change: GitChange) -> Color { switch change.kind { case .added: LitheTheme.success @@ -888,12 +898,13 @@ private struct GitWorktreeCreateView: View { let repositoryRoot: URL let references: [GitReference] let currentReference: GitReference? - let onSubmit: (String, GitReference, URL) -> Void + let onSubmit: (String, GitReference, String?, URL) -> Void @State private var branchName = "" @State private var selectedReferenceID = "" @State private var destinationPath = "" @State private var destinationWasEdited = false + @State private var revision = "" @FocusState private var branchFieldFocused: Bool var body: some View { @@ -949,6 +960,17 @@ private struct GitWorktreeCreateView: View { .fixedSize(horizontal: false, vertical: true) } + VStack(alignment: .leading, spacing: 6) { + Text("Starting commit (optional)") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("Use the selected branch tip", text: $revision) + .textFieldStyle(.roundedBorder) + Text("Enter a commit hash to create the new branch from that exact point.") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.tertiaryText) + } + HStack { Spacer() Button("Cancel") { dismiss() } @@ -956,7 +978,7 @@ private struct GitWorktreeCreateView: View { .lithePointer() Button("Create") { guard let selectedReference else { return } - onSubmit(trimmedBranchName, selectedReference, URL(fileURLWithPath: destinationPath)) + onSubmit(trimmedBranchName, selectedReference, revision.isEmpty ? nil : revision, URL(fileURLWithPath: destinationPath)) dismiss() } .buttonStyle(.borderedProminent) From a2c5853ca69da0c32e52ed72cb69363e97b19687 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:07:18 +0800 Subject: [PATCH 07/28] fix(core): use revision only when creating worktrees --- rust/lithe-core/src/git/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index e664de09b..5ff0fe45c 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -1584,11 +1584,6 @@ fn read_commit_log( ) -> Result<(Vec, bool), CoreError> { let mut arguments = vec!["log".to_string()]; arguments.extend(selectors); - let source = if let Some(revision) = request.revision.as_deref() { - validated_revision(Some(revision))? - } else { - reference.full_name.clone() - }; arguments.extend([ "--topo-order".to_string(), "--decorate=short".to_string(), @@ -4411,6 +4406,11 @@ fn create_worktree(root: &str, request: &GitWriteRequest) -> Result Date: Wed, 2 Sep 2026 22:11:16 +0800 Subject: [PATCH 08/28] fix(macos-ui): explain unavailable primary worktree actions --- .../Lithe/Views/Git/GitWorktreesView.swift | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index db93d3b83..f6d88962b 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -41,9 +41,15 @@ struct GitWorktreesView: View { } } + private struct WorktreeActionNotice: Identifiable { + let id = UUID() + let message: String + } + @EnvironmentObject private var model: AppModel @State private var showsCreateSheet = false @State private var removalConfirmation: RemovalConfirmation? + @State private var worktreeActionNotice: WorktreeActionNotice? @State private var showsPruneConfirmation = false @State private var searchText = "" @State private var selectedWorktreeID: String? @@ -124,6 +130,13 @@ struct GitWorktreesView: View { ) } } + .alert(item: $worktreeActionNotice) { notice in + Alert( + title: Text("Worktree action unavailable"), + message: Text(notice.message), + dismissButton: .default(Text("OK")) + ) + } .confirmationDialog( "Prune stale worktree records?", isPresented: $showsPruneConfirmation, @@ -437,14 +450,12 @@ struct GitWorktreesView: View { model.copyProjectItemPath(worktree.url, relative: false) } worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { - Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } + toggleLock(for: worktree) } - .disabled(worktree.isPrimary || worktree.isPrunable) .help(lockHelp(for: worktree)) worktreeAction("Remove Worktree…", icon: "trash", destructive: true) { - removalConfirmation = .regular(worktree) + requestRemoval(for: worktree) } - .disabled(worktree.isPrimary || worktree.isCurrent || worktree.isLocked || worktree.isPrunable) .help(removalHelp(for: worktree)) if worktree.isPrunable { worktreeAction("Repair Worktree Records", icon: "wrench.and.screwdriver") { @@ -538,9 +549,8 @@ struct GitWorktreesView: View { worktreeCard(title: "Worktree Settings") { informationRow("Protection", value: worktree.isLocked ? "Locked" : "Unlocked") worktreeAction(worktree.isLocked ? "Unlock Worktree" : "Lock Worktree", icon: worktree.isLocked ? "lock.open" : "lock") { - Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } + toggleLock(for: worktree) } - .disabled(worktree.isPrimary || worktree.isPrunable) .help(lockHelp(for: worktree)) } worktreeCard(title: "Maintenance") { @@ -556,9 +566,8 @@ struct GitWorktreesView: View { } worktreeCard(title: "Danger Zone") { worktreeAction("Remove Worktree…", icon: "trash", destructive: true) { - removalConfirmation = .regular(worktree) + requestRemoval(for: worktree) } - .disabled(worktree.isPrimary || worktree.isCurrent || worktree.isLocked || worktree.isPrunable) .help(removalHelp(for: worktree)) } } @@ -844,6 +853,25 @@ struct GitWorktreesView: View { return "" } + private func toggleLock(for worktree: GitWorktree) { + if worktree.isPrimary { + worktreeActionNotice = WorktreeActionNotice(message: String(localized: "The primary worktree cannot be locked.")) + } else if worktree.isPrunable { + worktreeActionNotice = WorktreeActionNotice(message: String(localized: "Repair or prune the missing checkout before changing its lock.")) + } else { + Task { await model.setGitWorktreeLocked(worktree, locked: !worktree.isLocked) } + } + } + + private func requestRemoval(for worktree: GitWorktree) { + let reason = removalHelp(for: worktree) + if !reason.isEmpty { + worktreeActionNotice = WorktreeActionNotice(message: reason) + } else { + removalConfirmation = .regular(worktree) + } + } + private func pathActionHelp(for worktree: GitWorktree) -> String { worktree.isPrunable ? String(localized: "The checkout path does not exist") : "" } From 9c51d0c6aa07ad23048ec08cd366d557fca40fe5 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:19:35 +0800 Subject: [PATCH 09/28] feat(macos-ui): add AI worktree directory option --- macos/Resources/en.lproj/Localizable.strings | 1 + macos/Resources/zh-Hans.lproj/Localizable.strings | 1 + .../Sources/Lithe/Views/Git/GitWorktreesView.swift | 13 +++++++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 0b9444d6d..502daf464 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -202,6 +202,7 @@ "Checkout path" = "Checkout path"; "Worktree destination" = "Worktree destination"; "Choose Parent…" = "Choose Parent…"; +"Use AI worktree directory (/private/tmp)" = "Use AI worktree directory (/private/tmp)"; "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts." = "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts."; "Create" = "Create"; "Enter a branch name" = "Enter a branch name"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 89f4c2958..e92013062 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1364,6 +1364,7 @@ "Checkout path" = "检出路径"; "Worktree destination" = "工作树目标路径"; "Choose Parent…" = "选择父目录…"; +"Use AI worktree directory (/private/tmp)" = "使用 AI 工作树目录(/private/tmp)"; "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts." = "建议将工作树放在仓库旁的持久目录中。临时检出时可以手动选择 /private/tmp。"; "Created worktree for %@" = "已为 %@ 创建工作树"; "Removed %@" = "已删除 %@"; diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index f6d88962b..829bca450 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -933,6 +933,7 @@ private struct GitWorktreeCreateView: View { @State private var destinationPath = "" @State private var destinationWasEdited = false @State private var revision = "" + @State private var useAIWorktreeDirectory = false @FocusState private var branchFieldFocused: Bool var body: some View { @@ -982,6 +983,12 @@ private struct GitWorktreeCreateView: View { } .lithePointer() } + Toggle("Use AI worktree directory (/private/tmp)", isOn: $useAIWorktreeDirectory) + .toggleStyle(.checkbox) + .onChange(of: useAIWorktreeDirectory) { _ in + destinationWasEdited = false + updateSuggestedDestination(force: true) + } Text("Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.") .font(.system(size: 10.5)) .foregroundStyle(LitheTheme.tertiaryText) @@ -1059,8 +1066,10 @@ private struct GitWorktreeCreateView: View { private func updateSuggestedDestination(force: Bool = false) { guard force || !destinationWasEdited else { return } - destinationPath = repositoryRoot - .deletingLastPathComponent() + let parent = useAIWorktreeDirectory + ? URL(fileURLWithPath: "/private/tmp", isDirectory: true) + : repositoryRoot.deletingLastPathComponent() + destinationPath = parent .appendingPathComponent(suggestedDirectoryName) .path destinationWasEdited = false From 6fa4c0b810457f99d844b387b16b7e54cf727c12 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:27:10 +0800 Subject: [PATCH 10/28] fix(macos): keep worktree history visible when status fails --- .../LitheGitModule/Application/GitFeatureModel.swift | 7 +++++-- macos/Sources/LitheGitModule/Services/GitService.swift | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 49b3be361..43c4028bf 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1655,8 +1655,11 @@ package final class GitFeatureModel: ObservableObject { gitWorktreeInspectionLoadState = .loading let reference = gitReferences.first { $0.fullName == worktree.branch } let inspection = await service.inspectWorktree(worktree, reference: reference) - guard generation == worktreeInspectionRequestGeneration, - !Task.isCancelled else { return } + guard generation == worktreeInspectionRequestGeneration else { return } + if Task.isCancelled { + gitWorktreeInspectionLoadState = .idle + return + } if let inspection { gitWorktreeInspection = inspection gitWorktreeInspectionLoadState = .ready diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 2fec6811f..5e51b5958 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -191,13 +191,16 @@ package struct GitService: Sendable { _ worktree: GitWorktree, reference: GitReference? ) async -> GitWorktreeInspection? { - async let snapshot = snapshot(for: worktree.url) async let history = history(at: worktree.url, reference: reference, limit: 300) - guard let snapshot = await snapshot else { return nil } + async let snapshot = snapshot(for: worktree.url) let resolvedHistory = await history + // A linked worktree can occasionally have a transiently unreadable + // index while Git is refreshing it. Keep the independent commit + // history visible instead of dropping the entire inspection result. + let resolvedChanges = (await snapshot)?.changes ?? [] return GitWorktreeInspection( worktreeID: worktree.id, - changes: snapshot.changes, + changes: resolvedChanges, commits: resolvedHistory.commits ) } From 60480f650f5e1eb48491cd6de16f8972ea9e0eeb Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:31:04 +0800 Subject: [PATCH 11/28] feat(macos): show repository path on worktree tab --- .../Sources/Lithe/Views/Git/GitLogView.swift | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 581385243..69b634041 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -260,7 +260,11 @@ struct GitLogView: View { .log, title: "Log: \(model.selectedGitReference?.shortName ?? model.currentBranch)" ) - gitToolTabButton(.worktrees, title: "Worktrees") + gitToolTabButton( + .worktrees, + title: "Worktrees", + detail: model.gitRepositoryRoot?.path + ) gitToolTabButton(.console, title: "Console") if selectedGitToolTab == .log { @@ -319,7 +323,11 @@ struct GitLogView: View { } } - private func gitToolTabButton(_ tab: GitToolTab, title: LocalizedStringKey) -> some View { + private func gitToolTabButton( + _ tab: GitToolTab, + title: LocalizedStringKey, + detail: String? = nil + ) -> some View { let isSelected = selectedGitToolTab == tab let showsCloseButton = isSelected && tab == .console return HStack(spacing: 0) { @@ -329,14 +337,23 @@ struct GitLogView: View { Task { await model.loadGitConsoleIfNeeded() } } } label: { - Text(title) - .font(GitVisual.toolbar) - .foregroundStyle(isSelected ? LitheTheme.primaryText : LitheTheme.secondaryText) - .lineLimit(1) - .padding(.leading, 9) - .padding(.trailing, showsCloseButton ? 4 : 9) - .frame(height: 27) - .contentShape(Rectangle()) + HStack(spacing: 5) { + Text(title) + if let detail, !detail.isEmpty { + Text("·") + .foregroundStyle(LitheTheme.tertiaryText) + Text(detail) + .foregroundStyle(LitheTheme.secondaryText) + .truncationMode(.middle) + } + } + .font(GitVisual.toolbar) + .foregroundStyle(isSelected ? LitheTheme.primaryText : LitheTheme.secondaryText) + .lineLimit(1) + .padding(.leading, 9) + .padding(.trailing, showsCloseButton ? 4 : 9) + .frame(height: 27) + .contentShape(Rectangle()) } .buttonStyle(.plain) .lithePointer() From 70ed2f307e4b7a81acda3003d1d483bef9ba0380 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:37:27 +0800 Subject: [PATCH 12/28] fix(macos): resolve run and git test diagnostics --- macos/Sources/Lithe/Views/Run/RunView.swift | 6 ------ macos/Tests/LitheGitModuleTests/GitModuleTests.swift | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index d0ad0099a..94fdbbf99 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -188,12 +188,6 @@ struct RunView: View { return (String(localized: "Project identification failed"), message, "xmark.octagon.fill") case .idle: return nil - case .projectNotReady: - return ( - String(localized: "Project is still loading"), - String(localized: "Wait for the workspace scan to finish, then identify the project again."), - "hourglass" - ) } } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 3fd92f074..b89c1a358 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -1658,7 +1658,7 @@ private struct TestGitOperations: GitOperations { func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { nil } - func createWorktree(named name: String, from reference: GitReference, at destination: URL, repositoryRoot: URL) -> GitProcessResult? { nil } + func createWorktree(named name: String, from reference: GitReference, revision: String?, at destination: URL, repositoryRoot: URL) -> GitProcessResult? { nil } func removeWorktree(_ worktree: GitWorktree, force: Bool, at rootURL: URL) -> GitProcessResult? { nil } func lockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? { nil } func unlockWorktree(_ worktree: GitWorktree, at rootURL: URL) -> GitProcessResult? { nil } From 8ca041f0753f06d013a03d1c2b254a0ec544c89c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:37:37 +0800 Subject: [PATCH 13/28] feat(macos): make worktree panes resizable --- .../Lithe/Views/Git/GitWorktreesView.swift | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index 829bca450..2e89e3822 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -54,18 +54,43 @@ struct GitWorktreesView: View { @State private var searchText = "" @State private var selectedWorktreeID: String? @State private var activeSection = WorktreeSection.overview + @State private var worktreeListWidth = Visual.listWidth + @State private var quickInfoPaneWidth = Visual.quickInfoWidth + @State private var leftDividerDragStart: CGFloat? + @State private var rightDividerDragStart: CGFloat? var body: some View { GeometryReader { geometry in HStack(spacing: 0) { worktreeListPane - .frame(width: min(Visual.listWidth, max(286, geometry.size.width * 0.29))) - Divider() + .frame(width: worktreeListWidth) + resizableDivider( + gesture: DragGesture(minimumDistance: 1) + .onChanged { value in + let start = leftDividerDragStart ?? worktreeListWidth + leftDividerDragStart = start + worktreeListWidth = min( + max(286, start + value.translation.width), + max(420, geometry.size.width - quickInfoPaneWidth - 500) + ) + } + .onEnded { _ in leftDividerDragStart = nil } + ) worktreeDetailPane if geometry.size.width >= Visual.quickInfoThreshold { - Divider() - quickInfoPane - .frame(width: Visual.quickInfoWidth) + resizableDivider( + gesture: DragGesture(minimumDistance: 1) + .onChanged { value in + let start = rightDividerDragStart ?? quickInfoPaneWidth + rightDividerDragStart = start + quickInfoPaneWidth = min( + max(220, start - value.translation.width), + max(360, geometry.size.width - worktreeListWidth - 500) + ) + } + .onEnded { _ in rightDividerDragStart = nil } + ) + quickInfoPane.frame(width: quickInfoPaneWidth) } } } @@ -151,6 +176,16 @@ struct GitWorktreesView: View { } } + private func resizableDivider(gesture: G) -> some View { + ZStack { + Divider() + Color.clear + } + .frame(width: 7) + .contentShape(Rectangle()) + .gesture(gesture) + } + private var worktreeListPane: some View { VStack(spacing: 0) { HStack(spacing: 10) { From 972cba9486f303de3d78ebdfae9b12f880de385a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:43:11 +0800 Subject: [PATCH 14/28] perf(macos): reuse cached primary worktree state --- .../Application/GitFeatureModel.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 43c4028bf..5373aebd3 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1652,6 +1652,21 @@ package final class GitFeatureModel: ObservableObject { gitWorktreeInspectionLoadState = .failed("The checkout path does not exist") return } + + // The primary checkout is already observed by the Git feature model. + // Reusing its status and history avoids a second full Git scan when + // opening the Worktrees tool window, while linked worktrees still use + // the dedicated inspection path below. + if worktree.isCurrent, !isLoadingGitHistory { + gitWorktreeInspection = GitWorktreeInspection( + worktreeID: worktree.id, + changes: gitChanges, + commits: gitCommits + ) + gitWorktreeInspectionLoadState = .ready + return + } + gitWorktreeInspectionLoadState = .loading let reference = gitReferences.first { $0.fullName == worktree.branch } let inspection = await service.inspectWorktree(worktree, reference: reference) From 28a1456026e9468484694597dc443331ce99d8fc Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:45:44 +0800 Subject: [PATCH 15/28] refactor(macos): reuse standard split handles for worktrees --- .../Lithe/Views/Git/GitWorktreesView.swift | 84 +++++++++---------- 1 file changed, 39 insertions(+), 45 deletions(-) diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index 2e89e3822..8c43dccc8 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -54,45 +54,49 @@ struct GitWorktreesView: View { @State private var searchText = "" @State private var selectedWorktreeID: String? @State private var activeSection = WorktreeSection.overview - @State private var worktreeListWidth = Visual.listWidth - @State private var quickInfoPaneWidth = Visual.quickInfoWidth - @State private var leftDividerDragStart: CGFloat? - @State private var rightDividerDragStart: CGFloat? var body: some View { GeometryReader { geometry in - HStack(spacing: 0) { - worktreeListPane - .frame(width: worktreeListWidth) - resizableDivider( - gesture: DragGesture(minimumDistance: 1) - .onChanged { value in - let start = leftDividerDragStart ?? worktreeListWidth - leftDividerDragStart = start - worktreeListWidth = min( - max(286, start + value.translation.width), - max(420, geometry.size.width - quickInfoPaneWidth - 500) - ) - } - .onEnded { _ in leftDividerDragStart = nil } - ) - worktreeDetailPane - if geometry.size.width >= Visual.quickInfoThreshold { - resizableDivider( - gesture: DragGesture(minimumDistance: 1) - .onChanged { value in - let start = rightDividerDragStart ?? quickInfoPaneWidth - rightDividerDragStart = start - quickInfoPaneWidth = min( - max(220, start - value.translation.width), - max(360, geometry.size.width - worktreeListWidth - 500) - ) - } - .onEnded { _ in rightDividerDragStart = nil } - ) - quickInfoPane.frame(width: quickInfoPaneWidth) + let showsQuickInfo = geometry.size.width >= Visual.quickInfoThreshold + let detailMinimum: CGFloat = showsQuickInfo ? 500 : 420 + let quickInfoMinimum: CGFloat = 220 + let listMaximum = max( + 286, + geometry.size.width + - SplitHandleView.thickness + - detailMinimum + - (showsQuickInfo ? SplitHandleView.thickness + quickInfoMinimum : 0) + ) + + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: Visual.listWidth, + minimum: 286, + maximum: listMaximum, + flexibleMinimum: detailMinimum + (showsQuickInfo ? SplitHandleView.thickness + quickInfoMinimum : 0), + sized: { worktreeListPane }, + flexible: { + if showsQuickInfo { + LitheSplitPaneView( + axis: .horizontal, + placement: .trailing, + defaultSize: Visual.quickInfoWidth, + minimum: quickInfoMinimum, + maximum: max( + quickInfoMinimum, + geometry.size.width - Visual.listWidth + - SplitHandleView.thickness - detailMinimum + ), + flexibleMinimum: detailMinimum, + sized: { quickInfoPane }, + flexible: { worktreeDetailPane } + ) + } else { + worktreeDetailPane + } } - } + ) } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) .task(id: model.gitRepositoryRoot) { @@ -176,16 +180,6 @@ struct GitWorktreesView: View { } } - private func resizableDivider(gesture: G) -> some View { - ZStack { - Divider() - Color.clear - } - .frame(width: 7) - .contentShape(Rectangle()) - .gesture(gesture) - } - private var worktreeListPane: some View { VStack(spacing: 0) { HStack(spacing: 10) { From f4a4613e8aa42d9ba0eda42c32a51dac5ff99c03 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:47:41 +0800 Subject: [PATCH 16/28] docs: require high-performance resizable UI patterns --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 60333b186..cc394b15f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,3 +29,30 @@ back. Do not launch duplicate Lithe instances during repeated checks, and do not leave test-built applications open in the user's application list. If a process cannot be stopped cleanly, report it explicitly and make a bounded best-effort cleanup before continuing. + +## 高性能 UI 交互与可调布局要求 + +涉及可拖拽分隔线、可调整面板、连续拖动、滚动或其他高频 UI 交互时,必须 +优先复用项目中已有的高性能布局容器和交互组件,不得为了快速实现而在业务 +父视图中直接堆叠自定义 `DragGesture`、逐事件写入多个 `@State` 或重复实现 +分隔线逻辑。 + +- macOS 的可调面板必须优先使用 `LitheSplitPaneView` 和 + `SplitHandleView`;如果确实无法复用,必须在变更说明中解释原因,并保持 + 相同的行为契约。 +- 拖拽处理必须使用稳定的坐标空间(连续拖动时优先使用全局坐标),避免因 + 分隔线自身移动导致坐标原点变化和拖拽跳动。 +- 高频拖拽事件必须经过节流、合并或死区过滤,不能在每个指针事件中触发 + 无必要的父视图重建;可变尺寸状态应尽量封装在局部布局容器内,避免拖动 + 使整个功能页面重新计算。 +- 必须提供明确的最小/最大尺寸和可用空间约束,保证相邻面板仍满足最低可用 + 宽度;窗口缩放、面板隐藏和重新出现时不得产生负尺寸或布局溢出。 +- 交互行为应与现有 Git、编辑器和工具窗口保持一致,包括悬停/拖拽高亮、 + 平台对应的调整光标、帮助文本和无障碍标签。 +- 如果尺寸需要跨刷新或重启保留,应通过现有布局持久化机制提交最终尺寸, + 不要在拖拽过程中持续写入持久化存储。 +- 新增或修改此类 UI 后,必须至少完成对应产品构建、`git diff --check` + 和相关边界检查;代码审查时应明确确认拖拽不会导致高频全页面重绘。 + +这些要求适用于 macOS 和 Windows:平台可使用各自的原生实现,但交互语义、 +性能目标、尺寸约束和可访问性要求必须保持一致。 From 29d8d9d888a22c5f2df11963b9c9ff954bbb32c3 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:55:06 +0800 Subject: [PATCH 17/28] perf(macos): load worktree history incrementally --- .../Application/GitFeatureModel.swift | 21 ++++++++++ .../LitheGitModule/Services/GitService.swift | 40 +++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 5373aebd3..92cfe81e0 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1678,6 +1678,27 @@ package final class GitFeatureModel: ObservableObject { if let inspection { gitWorktreeInspection = inspection gitWorktreeInspectionLoadState = .ready + + // Do not make the first detail render wait for the full history. + // The larger window is fetched after the lightweight inspection + // has already populated the pane, and stale selections are ignored. + guard inspection.commits.count >= 30 else { return } + Task { [weak self] in + guard let self else { return } + let fullHistory = await self.service.history( + at: worktree.url, + reference: reference, + limit: 300 + ) + guard generation == self.worktreeInspectionRequestGeneration, + self.gitWorktreeInspection?.worktreeID == worktree.id, + !Task.isCancelled else { return } + self.gitWorktreeInspection = GitWorktreeInspection( + worktreeID: worktree.id, + changes: inspection.changes, + commits: fullHistory.commits + ) + } } else { gitWorktreeInspection = nil gitWorktreeInspectionLoadState = .failed("Could not inspect this worktree") diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 5e51b5958..32b559878 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -129,10 +129,34 @@ package protocol GitOperations: Sendable { package typealias GitWatchContextProviding = LitheCoreContracts.GitWatchContextProviding +private actor GitHistoryCache { + private struct Key: Hashable { + let rootPath: String + let reference: String? + let limit: Int + } + + private var values: [Key: GitHistorySnapshot] = [:] + + func value(rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { + values[Key(rootPath: rootURL.standardizedFileURL.path, reference: reference?.fullName, limit: limit)] + } + + func insert(_ snapshot: GitHistorySnapshot, rootURL: URL, reference: GitReference?, limit: Int) { + let key = Key(rootPath: rootURL.standardizedFileURL.path, reference: reference?.fullName, limit: limit) + values[key] = snapshot + // Keep this process-local cache bounded while retaining the most useful recent queries. + if values.count > 24, let oldestKey = values.keys.first { + values.removeValue(forKey: oldestKey) + } + } +} + /// UI-facing Git service. Git command construction, validation, parsing, and /// process execution live behind the shared Rust operations port. package struct GitService: Sendable { private let operations: any GitOperations + private let historyCache = GitHistoryCache() package init(operations: any GitOperations) { self.operations = operations @@ -191,7 +215,9 @@ package struct GitService: Sendable { _ worktree: GitWorktree, reference: GitReference? ) async -> GitWorktreeInspection? { - async let history = history(at: worktree.url, reference: reference, limit: 300) + // The detail pane should become useful quickly; the feature model can + // request a larger window after this first paint. + async let history = history(at: worktree.url, reference: reference, limit: 30) async let snapshot = snapshot(for: worktree.url) let resolvedHistory = await history // A linked worktree can occasionally have a transiently unreadable @@ -379,9 +405,17 @@ package struct GitService: Sendable { reference: GitReference? = nil, limit: Int = 300 ) async -> GitHistorySnapshot { - await read(priority: .utility) { + if let cached = await historyCache.value(rootURL: repositoryRoot, reference: reference, limit: limit) { + return cached + } + let snapshot = await read(priority: .utility) { $0.history(at: repositoryRoot, reference: reference, limit: limit) - } ?? GitHistorySnapshot(references: [], commits: [], hasMore: false) + } + if let snapshot { + await historyCache.insert(snapshot, rootURL: repositoryRoot, reference: reference, limit: limit) + return snapshot + } + return GitHistorySnapshot(references: [], commits: [], hasMore: false) } func files(in commit: GitCommit, at repositoryRoot: URL) async -> [GitCommitFile]? { From e07474bfc4cd9990314b4851b2fa68bc729bd789 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:55:54 +0800 Subject: [PATCH 18/28] perf(macos): expire cached git history --- .../LitheGitModule/Services/GitService.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 32b559878..d92234dfe 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -136,15 +136,28 @@ private actor GitHistoryCache { let limit: Int } - private var values: [Key: GitHistorySnapshot] = [:] + private struct Entry { + let snapshot: GitHistorySnapshot + let insertedAt: Date + } + + private var values: [Key: Entry] = [:] func value(rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { - values[Key(rootPath: rootURL.standardizedFileURL.path, reference: reference?.fullName, limit: limit)] + let key = Key(rootPath: rootURL.standardizedFileURL.path, reference: reference?.fullName, limit: limit) + guard let entry = values[key] else { return nil } + // Short-lived reuse smooths repeated pane opens without allowing a + // commit made in the meantime to leave the UI stale indefinitely. + guard Date().timeIntervalSince(entry.insertedAt) < 5 else { + values.removeValue(forKey: key) + return nil + } + return entry.snapshot } func insert(_ snapshot: GitHistorySnapshot, rootURL: URL, reference: GitReference?, limit: Int) { let key = Key(rootPath: rootURL.standardizedFileURL.path, reference: reference?.fullName, limit: limit) - values[key] = snapshot + values[key] = Entry(snapshot: snapshot, insertedAt: Date()) // Keep this process-local cache bounded while retaining the most useful recent queries. if values.count > 24, let oldestKey = values.keys.first { values.removeValue(forKey: oldestKey) From 79b65ce45de92538e5afd78d3f9b47da085e39e2 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 22:59:41 +0800 Subject: [PATCH 19/28] perf(macos-ui): compact worktree history loading --- .../Lithe/Views/Git/GitWorktreesView.swift | 29 ++++++++++--------- .../Application/GitFeatureModel.swift | 4 ++- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index 8c43dccc8..99dbc4272 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -543,26 +543,29 @@ struct GitWorktreesView: View { if inspection.commits.isEmpty { worktreeMessage(icon: "clock.arrow.circlepath", title: "No commits", detail: "No commits were found for this branch.") } else { - worktreeCard(title: String(format: String(localized: "Commit History (%lld)"), inspection.commits.count)) { + worktreeCard(title: "Commit History") { VStack(spacing: 0) { ForEach(inspection.commits) { commit in - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 10) { Image(systemName: "circle.fill") .font(.system(size: 7)) .foregroundStyle(LitheTheme.accent) - .padding(.top, 5) - VStack(alignment: .leading, spacing: 4) { - Text(commit.subject) - .font(Visual.bodyMedium) - .foregroundStyle(LitheTheme.primaryText) - Text("\(commit.shortHash) · \(commit.authorName) · \(commit.date)") - .font(Visual.metadata) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } + Text(commit.subject) + .font(Visual.bodyMedium) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .truncationMode(.tail) Spacer() + Text(commit.authorName) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + Text(commit.date) + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) } - .padding(.vertical, 9) + .padding(.vertical, 8) if commit.id != inspection.commits.last?.id { Divider() } } } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 92cfe81e0..b20db65f8 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1688,7 +1688,9 @@ package final class GitFeatureModel: ObservableObject { let fullHistory = await self.service.history( at: worktree.url, reference: reference, - limit: 300 + // Keep the warm cache bounded; older commits can be + // requested later by pagination or an explicit search. + limit: 80 ) guard generation == self.worktreeInspectionRequestGeneration, self.gitWorktreeInspection?.worktreeID == worktree.id, From 654fbbaf7d10ddec9963775454399450043dfdc0 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 23:06:01 +0800 Subject: [PATCH 20/28] feat(macos): paginate worktree commit history --- .../AppModel/AppModel+GitOperations.swift | 5 ++ .../Lithe/Views/Git/GitWorktreesView.swift | 48 +++++++++++++++++-- .../Application/GitFeatureModel.swift | 23 ++++++++- .../LitheGitModule/Models/GitModels.swift | 4 +- .../LitheGitModule/Services/GitService.swift | 3 +- 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift index 5ddc0bd77..ab257fe08 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift @@ -146,6 +146,11 @@ extension AppModel { await gitFeature.inspectWorktree(worktree) } + func loadMoreGitWorktreeHistory(_ worktree: GitWorktree) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.loadMoreWorktreeHistory(for: worktree) + } + func createGitWorktree( named name: String, from reference: GitReference, diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index 99dbc4272..aac00d66b 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -52,6 +52,8 @@ struct GitWorktreesView: View { @State private var worktreeActionNotice: WorktreeActionNotice? @State private var showsPruneConfirmation = false @State private var searchText = "" + @State private var historySearchText = "" + @State private var isLoadingMoreHistory = false @State private var selectedWorktreeID: String? @State private var activeSection = WorktreeSection.overview @@ -544,8 +546,24 @@ struct GitWorktreesView: View { worktreeMessage(icon: "clock.arrow.circlepath", title: "No commits", detail: "No commits were found for this branch.") } else { worktreeCard(title: "Commit History") { - VStack(spacing: 0) { - ForEach(inspection.commits) { commit in + let query = historySearchText.trimmingCharacters(in: .whitespacesAndNewlines) + let commits = query.isEmpty ? inspection.commits : inspection.commits.filter { + $0.subject.localizedCaseInsensitiveContains(query) + || $0.authorName.localizedCaseInsensitiveContains(query) + || $0.hash.localizedCaseInsensitiveContains(query) + } + VStack(alignment: .leading, spacing: 10) { + TextField("Search commits", text: $historySearchText) + .textFieldStyle(.roundedBorder) + .font(Visual.body) + if commits.isEmpty { + Text("No matching commits in the loaded history.") + .font(Visual.metadata) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.vertical, 8) + } + VStack(spacing: 0) { + ForEach(commits) { commit in HStack(alignment: .firstTextBaseline, spacing: 10) { Image(systemName: "circle.fill") .font(.system(size: 7)) @@ -566,7 +584,31 @@ struct GitWorktreesView: View { .lineLimit(1) } .padding(.vertical, 8) - if commit.id != inspection.commits.last?.id { Divider() } + if commit.id != commits.last?.id { Divider() } + } + } + if query.isEmpty && inspection.hasMoreCommits { + Button { + guard !isLoadingMoreHistory else { return } + isLoadingMoreHistory = true + Task { + await model.loadMoreGitWorktreeHistory(worktree) + isLoadingMoreHistory = false + } + } label: { + HStack { + Spacer() + if isLoadingMoreHistory { + ProgressView().controlSize(.small) + } else { + Text("Load More") + } + Spacer() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isLoadingMoreHistory) } } } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index b20db65f8..718699609 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1661,7 +1661,8 @@ package final class GitFeatureModel: ObservableObject { gitWorktreeInspection = GitWorktreeInspection( worktreeID: worktree.id, changes: gitChanges, - commits: gitCommits + commits: gitCommits, + hasMoreCommits: false ) gitWorktreeInspectionLoadState = .ready return @@ -1698,7 +1699,8 @@ package final class GitFeatureModel: ObservableObject { self.gitWorktreeInspection = GitWorktreeInspection( worktreeID: worktree.id, changes: inspection.changes, - commits: fullHistory.commits + commits: fullHistory.commits, + hasMoreCommits: fullHistory.hasMore ) } } else { @@ -1707,6 +1709,23 @@ package final class GitFeatureModel: ObservableObject { } } + package func loadMoreWorktreeHistory(for worktree: GitWorktree) async { + guard let inspection = gitWorktreeInspection, + inspection.worktreeID == worktree.id, + inspection.hasMoreCommits, + !Task.isCancelled else { return } + let reference = gitReferences.first { $0.fullName == worktree.branch } + let nextLimit = inspection.commits.count + 50 + let history = await service.history(at: worktree.url, reference: reference, limit: nextLimit) + guard gitWorktreeInspection?.worktreeID == worktree.id else { return } + gitWorktreeInspection = GitWorktreeInspection( + worktreeID: worktree.id, + changes: inspection.changes, + commits: history.commits, + hasMoreCommits: history.hasMore + ) + } + package func createWorktree( named rawName: String, from reference: GitReference, diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index e244ea2bf..6e0457b92 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -28,11 +28,13 @@ package struct GitWorktreeInspection: Sendable { package let worktreeID: String package let changes: [GitChange] package let commits: [GitCommit] + package let hasMoreCommits: Bool - package init(worktreeID: String, changes: [GitChange], commits: [GitCommit]) { + package init(worktreeID: String, changes: [GitChange], commits: [GitCommit], hasMoreCommits: Bool = false) { self.worktreeID = worktreeID self.changes = changes self.commits = commits + self.hasMoreCommits = hasMoreCommits } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index d92234dfe..9b55a66bc 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -240,7 +240,8 @@ package struct GitService: Sendable { return GitWorktreeInspection( worktreeID: worktree.id, changes: resolvedChanges, - commits: resolvedHistory.commits + commits: resolvedHistory.commits, + hasMoreCommits: resolvedHistory.hasMore ) } From 2b493f01f1ff514f8de12c528bb887e3662808c9 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 23:07:06 +0800 Subject: [PATCH 21/28] fix(macos): cap primary worktree history page --- .../Sources/LitheGitModule/Application/GitFeatureModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 718699609..ad587ab76 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1661,8 +1661,8 @@ package final class GitFeatureModel: ObservableObject { gitWorktreeInspection = GitWorktreeInspection( worktreeID: worktree.id, changes: gitChanges, - commits: gitCommits, - hasMoreCommits: false + commits: Array(gitCommits.prefix(80)), + hasMoreCommits: gitCommits.count > 80 || canLoadMoreGitHistory ) gitWorktreeInspectionLoadState = .ready return From 3c592188c42bda91f104d48a0a049a728729ca27 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 23:38:59 +0800 Subject: [PATCH 22/28] docs: translate AGENTS instructions to Chinese --- AGENTS.md | 56 +++++++++++++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc394b15f..b36e4c60d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,34 +1,28 @@ -# Lithe Agent Entry Point - -Before any work in this repository, load and follow the `develop-lithe` skill -at `.agents/skills/develop-lithe/SKILL.md`. That Skill is the single source of -truth for AI coding and verification rules, including the required Rust Core -comment standard. - -If a task creates, modifies, or reviews test code or test infrastructure, -additionally load `.agents/skills/write-stable-tests/SKILL.md` before -proceeding. That Skill defines the mandatory bounded-wait, deterministic-time, -cleanup, and per-test timing rules for both macOS and Windows. - -If a task prepares, validates, or publishes a stable Lithe release, -additionally load `.agents/skills/release-lithe/SKILL.md` before changing -release notes, version metadata, tags, or release workflows. - -If the task involves building, running, diagnosing, or transferring files to the -Windows product through a Parallels guest VM, additionally load -`.agents/skills/debug-windows-on-parallels/SKILL.md` before proceeding. - -## Test process lifecycle and cleanup - -Unless the user gives a specific instruction to keep a process running, any -Lithe application started for building, testing, debugging, previewing, or -verification must be shut down when the task or test run is complete. Clean up -all child processes, helper processes, temporary app instances, and related -resources, then verify that no Lithe processes remain before handing the work -back. Do not launch duplicate Lithe instances during repeated checks, and do -not leave test-built applications open in the user's application list. If a -process cannot be stopped cleanly, report it explicitly and make a bounded -best-effort cleanup before continuing. +# Lithe 代理入口 + +在本仓库中开展任何工作之前,必须加载并遵循位于 +`.agents/skills/develop-lithe/SKILL.md` 的 `develop-lithe` 技能。该技能是 +人工智能编码与验证规则的唯一准则,其中包括 Rust Core 必须遵循的注释规范。 + +如果任务会创建、修改或审查测试代码或测试基础设施,则还必须在继续之前加载 +`.agents/skills/write-stable-tests/SKILL.md`。该技能规定了 macOS 和 Windows +都必须遵循的有界等待、确定性时间、清理以及单测试计时规则。 + +如果任务涉及准备、验证或发布稳定版 Lithe,则必须在修改发行说明、版本元数据、 +标签或发布工作流之前,额外加载 `.agents/skills/release-lithe/SKILL.md`。 + +如果任务涉及通过 Parallels 虚拟机来构建、运行、诊断 Windows 产品,或向其中 +传输文件,则必须在继续之前额外加载 +`.agents/skills/debug-windows-on-parallels/SKILL.md`。 + +## 测试进程生命周期与清理 + +除非用户明确要求保持进程运行,否则,任何为构建、测试、调试、预览或验证而 +启动的 Lithe 应用,都必须在任务或测试运行结束时关闭。清理所有子进程、辅助 +进程、临时应用实例及相关资源,并在交付工作之前确认没有残留的 Lithe 进程。 +重复检查期间不得启动重复的 Lithe 实例,也不得让测试构建的应用继续出现在 +用户的应用列表中。如果某个进程无法正常停止,必须明确报告,并在继续之前 +尽最大努力进行有界清理。 ## 高性能 UI 交互与可调布局要求 From 11e43368ccf03affeaed87b667d2351425d878ef Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 23:58:37 +0800 Subject: [PATCH 23/28] =?UTF-8?q?fix(macos):=20=E6=8F=90=E4=BA=A4=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=BC=98=E5=85=88=E6=98=BE=E7=A4=BA=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20Git=20=E8=80=97=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Sources/Lithe/LitheApp.swift | 4 +- .../Logging/MacApplicationLogWriter.swift | 29 +++- .../Platform/MacOS/MacServiceContainer.swift | 6 +- .../Lithe/Views/Git/GitWorktreesView.swift | 4 +- .../Application/GitFeatureModel.swift | 114 +++++++++++----- .../LitheGitModule/Models/GitModels.swift | 10 +- .../LitheGitModule/Module/GitModule.swift | 10 +- .../LitheGitModule/Services/GitService.swift | 127 +++++++++++++++--- .../LitheGitModuleTests/GitModuleTests.swift | 108 ++++++++++++++- 9 files changed, 351 insertions(+), 61 deletions(-) diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 2921b184a..86eb93a01 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -217,6 +217,7 @@ struct LitheApp: App { settings?.setCustomLogDirectory(nil) } self.applicationLogWriter = applicationLogWriter + let gitPerformanceLogger = MacGitPerformanceLogger(writer: applicationLogWriter) MacBundledFontRegistry.registerFonts { message in Self.appendApplicationLog(applicationLogWriter, message: message) } @@ -240,7 +241,8 @@ struct LitheApp: App { : .normal, moduleStore: moduleStore, pluginRuntimeRecovery: pluginRuntimeRecovery, - authorizationCallbackRouter: authorizationCallbackRouter + authorizationCallbackRouter: authorizationCallbackRouter, + gitPerformanceLogger: gitPerformanceLogger ).services ) }, diff --git a/macos/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift b/macos/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift index c40670940..e139563b4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift @@ -1,7 +1,8 @@ import Darwin import Foundation +import LitheGitModule -final class MacApplicationLogWriter { +final class MacApplicationLogWriter: @unchecked Sendable { static let fileName = "lithe.log" private let lock = NSLock() @@ -53,3 +54,29 @@ final class MacApplicationLogWriter { } } } + + +/// Bridges Git performance diagnostics into the application's configured log file. +struct MacGitPerformanceLogger: GitPerformanceLogger, Sendable { + private let writer: MacApplicationLogWriter + private let queue = DispatchQueue( + label: "com.openres.Lithe.git-performance-log", + qos: .utility + ) + + init(writer: MacApplicationLogWriter) { + self.writer = writer + } + + func record(_ message: String) { + let timestampMilliseconds = Int(Date().timeIntervalSince1970 * 1_000) + queue.async { [writer] in + do { + try writer.append("timestamp_ms=\(timestampMilliseconds) \(message)\n") + } catch { + let fallback = "Could not write Git performance log: \(error.localizedDescription)\n" + FileHandle.standardError.write(Data(fallback.utf8)) + } + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index f80ddb0e7..617435104 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -58,7 +58,8 @@ final class MacServiceContainer { runExecutableResolver providedRunExecutableResolver: (any RunExecutableResolving)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil, - platformUI providedPlatformUI: (any PlatformUI)? = nil + platformUI providedPlatformUI: (any PlatformUI)? = nil, + gitPerformanceLogger: (any GitPerformanceLogger)? = nil ) { let authorizationCallbackRouter = providedAuthorizationCallbackRouter ?? MacExternalAuthorizationCallbackRouter() @@ -443,7 +444,8 @@ final class MacServiceContainer { try moduleRegistry.register(ModuleFactory(manifest: GitModule.moduleManifest, contributions: GitModule.moduleContributions) { GitModule( operations: gitOperations, - shelfStorage: MacGitShelfStorage(storage: fileStorage) + shelfStorage: MacGitShelfStorage(storage: fileStorage), + performanceLogger: gitPerformanceLogger ?? NullGitPerformanceLogger() ) }) try moduleRegistry.register(ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) { diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index aac00d66b..15bfb81ef 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -506,7 +506,9 @@ struct GitWorktreesView: View { if worktree.isPrunable { missingPathState } else if let inspection = matchingInspection(for: worktree) { - if inspection.changes.isEmpty { + if !inspection.hasLoadedChanges { + inspectionState + } else if inspection.changes.isEmpty { worktreeMessage(icon: "checkmark.circle", title: "No local changes", detail: "This worktree has no uncommitted changes.") } else { worktreeCard(title: "Changes") { diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index ad587ab76..0ff7d6d6b 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -622,6 +622,13 @@ package final class GitFeatureModel: ObservableObject { recordGitConsoleEntry(result) } + private func elapsedMilliseconds(since startedAt: ContinuousClock.Instant) -> Int { + let components = startedAt.duration(to: .now).components + let milliseconds = (Double(components.seconds) * 1_000) + + (Double(components.attoseconds) / 1_000_000_000_000_000) + return max(0, Int(milliseconds.rounded())) + } + private func recordGitConsoleEntry(_ result: GitService.CommandResult) { guard let workingDirectory = result.workingDirectory ?? gitRepositoryRoot else { return } if result.invocations.isEmpty { @@ -1647,6 +1654,7 @@ package final class GitFeatureModel: ObservableObject { package func inspectWorktree(_ worktree: GitWorktree) async { worktreeInspectionRequestGeneration &+= 1 let generation = worktreeInspectionRequestGeneration + let inspectionStartedAt = ContinuousClock.now guard !worktree.isPrunable else { gitWorktreeInspection = nil gitWorktreeInspectionLoadState = .failed("The checkout path does not exist") @@ -1654,9 +1662,7 @@ package final class GitFeatureModel: ObservableObject { } // The primary checkout is already observed by the Git feature model. - // Reusing its status and history avoids a second full Git scan when - // opening the Worktrees tool window, while linked worktrees still use - // the dedicated inspection path below. + // Reuse its settled state without starting another Git scan. if worktree.isCurrent, !isLoadingGitHistory { gitWorktreeInspection = GitWorktreeInspection( worktreeID: worktree.id, @@ -1665,47 +1671,85 @@ package final class GitFeatureModel: ObservableObject { hasMoreCommits: gitCommits.count > 80 || canLoadMoreGitHistory ) gitWorktreeInspectionLoadState = .ready + service.recordWorktreeInspection( + worktreeID: worktree.id, + phase: "reused-state", + durationMilliseconds: elapsedMilliseconds(since: inspectionStartedAt) + ) return } gitWorktreeInspectionLoadState = .loading let reference = gitReferences.first { $0.fullName == worktree.branch } - let inspection = await service.inspectWorktree(worktree, reference: reference) - guard generation == worktreeInspectionRequestGeneration else { return } - if Task.isCancelled { + // History is the primary content of this pane. Start it independently + // from the worktree status scan so a slow or unreadable index cannot + // keep the commit list behind the loading state. + async let history = service.history(at: worktree.url, reference: reference, limit: 30) + async let snapshot = service.snapshot(for: worktree.url) + + let resolvedHistory = await history + guard generation == worktreeInspectionRequestGeneration, !Task.isCancelled else { gitWorktreeInspectionLoadState = .idle return } - if let inspection { - gitWorktreeInspection = inspection - gitWorktreeInspectionLoadState = .ready - // Do not make the first detail render wait for the full history. - // The larger window is fetched after the lightweight inspection - // has already populated the pane, and stale selections are ignored. - guard inspection.commits.count >= 30 else { return } - Task { [weak self] in - guard let self else { return } - let fullHistory = await self.service.history( - at: worktree.url, - reference: reference, - // Keep the warm cache bounded; older commits can be - // requested later by pagination or an explicit search. - limit: 80 - ) - guard generation == self.worktreeInspectionRequestGeneration, - self.gitWorktreeInspection?.worktreeID == worktree.id, - !Task.isCancelled else { return } - self.gitWorktreeInspection = GitWorktreeInspection( - worktreeID: worktree.id, - changes: inspection.changes, - commits: fullHistory.commits, - hasMoreCommits: fullHistory.hasMore - ) - } - } else { - gitWorktreeInspection = nil - gitWorktreeInspectionLoadState = .failed("Could not inspect this worktree") + let initialChanges = worktree.isCurrent ? gitChanges : [] + let initialInspection = GitWorktreeInspection( + worktreeID: worktree.id, + changes: initialChanges, + commits: resolvedHistory.commits, + hasMoreCommits: resolvedHistory.hasMore, + hasLoadedChanges: worktree.isCurrent + ) + gitWorktreeInspection = initialInspection + gitWorktreeInspectionLoadState = .ready + service.recordWorktreeInspection( + worktreeID: worktree.id, + phase: "history-published", + durationMilliseconds: elapsedMilliseconds(since: inspectionStartedAt) + ) + + // Do not make the first detail render wait for the full status scan. + // The larger history window and the status result are both applied only + // if this worktree is still selected. + let resolvedChanges = (await snapshot)?.changes ?? [] + guard generation == worktreeInspectionRequestGeneration, + gitWorktreeInspection?.worktreeID == worktree.id, + !Task.isCancelled else { return } + let inspection = GitWorktreeInspection( + worktreeID: worktree.id, + changes: resolvedChanges, + commits: gitWorktreeInspection?.commits ?? resolvedHistory.commits, + hasMoreCommits: gitWorktreeInspection?.hasMoreCommits ?? resolvedHistory.hasMore, + hasLoadedChanges: true + ) + gitWorktreeInspection = inspection + service.recordWorktreeInspection( + worktreeID: worktree.id, + phase: "changes-published", + durationMilliseconds: elapsedMilliseconds(since: inspectionStartedAt) + ) + + guard inspection.commits.count >= 30 else { return } + Task { [weak self] in + guard let self else { return } + let fullHistory = await self.service.history( + at: worktree.url, + reference: reference, + // Keep the warm cache bounded; older commits can be + // requested later by pagination or an explicit search. + limit: 80 + ) + guard generation == self.worktreeInspectionRequestGeneration, + self.gitWorktreeInspection?.worktreeID == worktree.id, + !Task.isCancelled else { return } + self.gitWorktreeInspection = GitWorktreeInspection( + worktreeID: worktree.id, + changes: self.gitWorktreeInspection?.changes ?? resolvedChanges, + commits: fullHistory.commits, + hasMoreCommits: fullHistory.hasMore, + hasLoadedChanges: self.gitWorktreeInspection?.hasLoadedChanges ?? true + ) } } diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 6e0457b92..a2bfbec09 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -29,12 +29,20 @@ package struct GitWorktreeInspection: Sendable { package let changes: [GitChange] package let commits: [GitCommit] package let hasMoreCommits: Bool + package let hasLoadedChanges: Bool - package init(worktreeID: String, changes: [GitChange], commits: [GitCommit], hasMoreCommits: Bool = false) { + package init( + worktreeID: String, + changes: [GitChange], + commits: [GitCommit], + hasMoreCommits: Bool = false, + hasLoadedChanges: Bool = true + ) { self.worktreeID = worktreeID self.changes = changes self.commits = commits self.hasMoreCommits = hasMoreCommits + self.hasLoadedChanges = hasLoadedChanges } } diff --git a/macos/Sources/LitheGitModule/Module/GitModule.swift b/macos/Sources/LitheGitModule/Module/GitModule.swift index 64918f664..776bb7d9a 100644 --- a/macos/Sources/LitheGitModule/Module/GitModule.swift +++ b/macos/Sources/LitheGitModule/Module/GitModule.swift @@ -18,17 +18,23 @@ public final class GitModule: LitheModule { public let manifest = moduleManifest private let operations: any GitOperations private let shelfStorage: any GitShelfStorage + private let performanceLogger: any GitPerformanceLogger private var capability: GitModuleCapability? - package init(operations: any GitOperations, shelfStorage: any GitShelfStorage) { + package init( + operations: any GitOperations, + shelfStorage: any GitShelfStorage, + performanceLogger: any GitPerformanceLogger = NullGitPerformanceLogger() + ) { self.operations = operations self.shelfStorage = shelfStorage + self.performanceLogger = performanceLogger } public func activate(context: ModuleContext) async throws { guard capability == nil else { return } let feature = GitFeatureModel( - service: GitService(operations: operations), + service: GitService(operations: operations, performanceLogger: performanceLogger), shelveService: ShelveService(storage: shelfStorage) ) feature.configureModuleLeases { reason in diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 9b55a66bc..5e73f8a4a 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -1,6 +1,16 @@ import Foundation import LitheCoreContracts +package protocol GitPerformanceLogger: Sendable { + func record(_ message: String) +} + +package struct NullGitPerformanceLogger: GitPerformanceLogger { + package init() {} + + package func record(_ message: String) {} +} + package protocol GitOperations: Sendable { func run( arguments: [String], @@ -170,9 +180,14 @@ private actor GitHistoryCache { package struct GitService: Sendable { private let operations: any GitOperations private let historyCache = GitHistoryCache() + private let performanceLogger: any GitPerformanceLogger - package init(operations: any GitOperations) { + package init( + operations: any GitOperations, + performanceLogger: any GitPerformanceLogger = NullGitPerformanceLogger() + ) { self.operations = operations + self.performanceLogger = performanceLogger } package struct CommandResult: Sendable { @@ -419,7 +434,14 @@ package struct GitService: Sendable { reference: GitReference? = nil, limit: Int = 300 ) async -> GitHistorySnapshot { + let historyLookupStartedAt = ContinuousClock.now if let cached = await historyCache.value(rootURL: repositoryRoot, reference: reference, limit: limit) { + performanceLogger.record( + GitPerformanceLogFormatter.cacheHit( + operation: #function, + durationMilliseconds: elapsedMilliseconds(since: historyLookupStartedAt) + ) + ) return cached } let snapshot = await read(priority: .utility) { @@ -758,35 +780,106 @@ package struct GitService: Sendable { private func command( at workingDirectory: URL? = nil, fallbackArguments: [String] = [], + operationName: String = #function, _ operation: @escaping @Sendable (any GitOperations) -> GitProcessResult? ) async -> CommandResult { let operations = self.operations - return await Task.detached(priority: .userInitiated) { - let result = operation(operations) - return CommandResult( + let startedAt = ContinuousClock.now + let result = await Task.detached(priority: .userInitiated) { + operation(operations) + }.value + let commandResult = CommandResult( + workingDirectory: workingDirectory, + arguments: result?.arguments.isEmpty == false + ? result?.arguments ?? fallbackArguments + : fallbackArguments, + output: result?.output ?? "Rust Core Git operation failed", + standardOutput: result?.standardOutput, + standardError: result?.standardError, + exitCode: result?.exitCode ?? 1, + invocations: result?.invocations ?? [], + operationErrorMessage: result?.operationErrorMessage, + stashRestoreConflict: result?.stashRestoreConflict, + warnings: result?.warnings ?? [] + ) + performanceLogger.record( + GitPerformanceLogFormatter.command( + operation: operationName, workingDirectory: workingDirectory, - arguments: result?.arguments.isEmpty == false - ? result?.arguments ?? fallbackArguments - : fallbackArguments, - output: result?.output ?? "Rust Core Git operation failed", - standardOutput: result?.standardOutput, - standardError: result?.standardError, - exitCode: result?.exitCode ?? 1, - invocations: result?.invocations ?? [], - operationErrorMessage: result?.operationErrorMessage, - stashRestoreConflict: result?.stashRestoreConflict, - warnings: result?.warnings ?? [] + arguments: commandResult.arguments, + durationMilliseconds: elapsedMilliseconds(since: startedAt), + succeeded: commandResult.succeeded ) - }.value + ) + return commandResult } private func read( priority: TaskPriority = .userInitiated, + operationName: String = #function, _ operation: @escaping @Sendable (any GitOperations) -> T? ) async -> T? { let operations = self.operations - return await Task.detached(priority: priority) { + let startedAt = ContinuousClock.now + let result = await Task.detached(priority: priority) { operation(operations) }.value + performanceLogger.record( + GitPerformanceLogFormatter.read( + operation: operationName, + durationMilliseconds: elapsedMilliseconds(since: startedAt), + succeeded: result != nil + ) + ) + return result + } + + package func recordWorktreeInspection( + worktreeID: String, + phase: String, + durationMilliseconds: Int + ) { + performanceLogger.record( + "[git-performance] operation=worktree-inspection phase=\(phase) worktree=\(GitPerformanceLogFormatter.redact(worktreeID)) duration_ms=\(durationMilliseconds)" + ) + } + + private func elapsedMilliseconds(since startedAt: ContinuousClock.Instant) -> Int { + let components = startedAt.duration(to: .now).components + let milliseconds = (Double(components.seconds) * 1_000) + + (Double(components.attoseconds) / 1_000_000_000_000_000) + return max(0, Int(milliseconds.rounded())) + } +} + +private enum GitPerformanceLogFormatter { + static func command( + operation: String, + workingDirectory: URL?, + arguments: [String], + durationMilliseconds: Int, + succeeded: Bool + ) -> String { + let command = GitConsoleCommandFormatter.commandLine(arguments: arguments) + let directory = workingDirectory?.path ?? "-" + return "[git-performance] operation=\(redact(operation)) duration_ms=\(durationMilliseconds) status=\(succeeded ? "success" : "failure") cwd=\(redact(directory)) command=\(redact(command))" + } + + static func read( + operation: String, + durationMilliseconds: Int, + succeeded: Bool + ) -> String { + "[git-performance] operation=\(redact(operation)) duration_ms=\(durationMilliseconds) status=\(succeeded ? "success" : "failure") cache=miss" + } + + static func cacheHit(operation: String, durationMilliseconds: Int) -> String { + "[git-performance] operation=\(redact(operation)) duration_ms=\(durationMilliseconds) status=success cache=hit" + } + + static func redact(_ value: String) -> String { + GitConsoleRedactor.redact(value) + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\n", with: "\\n") } } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index b89c1a358..950b702ff 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -317,6 +317,29 @@ struct GitModuleTests { ]) } + @Test + func gitServiceRecordsElapsedTimeForHistoryOperations() async { + let root = URL(fileURLWithPath: "/workspace") + let logger = GitPerformanceLogRecorder() + let service = GitService( + operations: TestGitOperations(historyValue: GitHistorySnapshot( + references: [], + recentReferences: [], + commits: [], + hasMore: false + )), + performanceLogger: logger + ) + + _ = await service.history(at: root, limit: 30) + + let messages = logger.messages + #expect(messages.count == 1) + #expect(messages.first?.contains("operation=history") == true) + #expect(messages.first?.contains("duration_ms=") == true) + #expect(messages.first?.contains("status=success") == true) + } + @Test func gitHistoryPublishesRecentReferencesInCoreOrder() async { let root = URL(fileURLWithPath: "/workspace") @@ -912,6 +935,53 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.first?.workingDirectory == secondRoot) } + @Test + func worktreeInspectionPublishesHistoryBeforeStatusScanFinishes() async throws { + let worktree = makeTestWorktree(path: "/workspace-feature", branch: "feature/history") + let change = GitChange( + repositoryRoot: URL(fileURLWithPath: worktree.path), + path: "Sources/App.swift", + originalPath: nil, + indexStatus: " ", + workTreeStatus: "M" + ) + let commit = makeTestCommit(hash: "history-commit", subject: "Show history first") + let snapshotGate = GitModuleTestGate() + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot( + repositoryRoot: URL(fileURLWithPath: worktree.path), + branch: "feature/history", + changes: [change] + ), + historyValue: GitHistorySnapshot( + references: [], + recentReferences: [], + commits: [commit], + hasMore: false + ), + snapshotGate: snapshotGate + )) + let feature = GitFeatureModel(service: service) + let inspectionTask = Task { @MainActor in + await feature.inspectWorktree(worktree) + } + defer { + inspectionTask.cancel() + snapshotGate.open() + } + + let partialInspection = await waitForGitWorktreeInspection(feature) + try #require(partialInspection != nil) + #expect(partialInspection?.commits == [commit]) + #expect(partialInspection?.hasLoadedChanges == false) + #expect(partialInspection?.changes.isEmpty == true) + + snapshotGate.open() + try #require(await waitForGitTaskCompletion(inspectionTask)) + #expect(feature.gitWorktreeInspection?.changes == [change]) + #expect(feature.gitWorktreeInspection?.hasLoadedChanges == true) + } + @Test func newerWorktreeRefreshWinsWhenAnOlderRequestFinishesLast() async throws { let root = URL(fileURLWithPath: "/workspace") @@ -1559,6 +1629,19 @@ private func waitForGitTaskCompletion( } } +@MainActor +private func waitForGitWorktreeInspection( + _ feature: GitFeatureModel, + timeout: Duration = .seconds(2) +) async -> GitWorktreeInspection? { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while feature.gitWorktreeInspection == nil, clock.now < deadline { + await Task.yield() + } + return feature.gitWorktreeInspection +} + @MainActor private func waitForGitWorkToBecomeIdle( timeout: Duration = .seconds(2), @@ -1572,6 +1655,23 @@ private func waitForGitWorkToBecomeIdle( return !isActive() } +private final class GitPerformanceLogRecorder: GitPerformanceLogger, @unchecked Sendable { + private let lock = NSLock() + private var recordedMessages: [String] = [] + + var messages: [String] { + lock.lock() + defer { lock.unlock() } + return recordedMessages + } + + func record(_ message: String) { + lock.lock() + recordedMessages.append(message) + lock.unlock() + } +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? @@ -1581,6 +1681,7 @@ private struct TestGitOperations: GitOperations { private let comparisonDiffDocumentValue: DiffDocument? private let typedComparisonDiffDocumentValue: DiffDocument? private let historyValue: GitHistorySnapshot? + private let snapshotGate: GitModuleTestGate? private let stageResult: GitProcessResult? private let runGate: TestGitRunGate? private let filesRecorder: GitFilesCallRecorder? @@ -1595,6 +1696,7 @@ private struct TestGitOperations: GitOperations { untrackedDiffDocumentValue: DiffDocument? = nil, comparisonDiffDocumentValue: DiffDocument? = nil, typedComparisonDiffDocumentValue: DiffDocument? = nil, + snapshotGate: GitModuleTestGate? = nil, stageResult: GitProcessResult? = nil, runGate: TestGitRunGate? = nil, filesRecorder: GitFilesCallRecorder? = nil, @@ -1608,6 +1710,7 @@ private struct TestGitOperations: GitOperations { self.untrackedDiffDocumentValue = untrackedDiffDocumentValue self.comparisonDiffDocumentValue = comparisonDiffDocumentValue self.typedComparisonDiffDocumentValue = typedComparisonDiffDocumentValue + self.snapshotGate = snapshotGate self.stageResult = stageResult self.runGate = runGate self.filesRecorder = filesRecorder @@ -1625,7 +1728,10 @@ private struct TestGitOperations: GitOperations { ) } - func snapshot(at rootURL: URL) -> GitSnapshot? { snapshotValue } + func snapshot(at rootURL: URL) -> GitSnapshot? { + _ = snapshotGate?.waitSynchronously() + return snapshotValue + } func watchContext(at rootURL: URL) -> GitWatchContext? { nil } func worktrees(at rootURL: URL) -> [GitWorktree]? { nil } func diffDocument(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> DiffDocument? { From ffc7715123103853c31957ec1d6a0e2512a96857 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 3 Sep 2026 00:08:31 +0800 Subject: [PATCH 24/28] fix(macos): clear deleted worktree details --- .../LitheGitModule/Application/GitFeatureModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 0ff7d6d6b..2f5250504 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1818,6 +1818,12 @@ package final class GitFeatureModel: ObservableObject { worktree.displayName ) : trimmedMessage(result)) + if result.succeeded, gitWorktreeInspection?.worktreeID == worktree.id { + // Clear deleted checkout details before the registry refresh so + // the UI cannot keep rendering a removed path. + gitWorktreeInspection = nil + gitWorktreeInspectionLoadState = .idle + } await refreshWorktrees() } From be1f25d4b8c407be36faacb0610ae0ba1302846b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 3 Sep 2026 00:10:00 +0800 Subject: [PATCH 25/28] docs: translate AGENTS instructions to English --- AGENTS.md | 121 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b36e4c60d..422555f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,52 +1,69 @@ -# Lithe 代理入口 - -在本仓库中开展任何工作之前,必须加载并遵循位于 -`.agents/skills/develop-lithe/SKILL.md` 的 `develop-lithe` 技能。该技能是 -人工智能编码与验证规则的唯一准则,其中包括 Rust Core 必须遵循的注释规范。 - -如果任务会创建、修改或审查测试代码或测试基础设施,则还必须在继续之前加载 -`.agents/skills/write-stable-tests/SKILL.md`。该技能规定了 macOS 和 Windows -都必须遵循的有界等待、确定性时间、清理以及单测试计时规则。 - -如果任务涉及准备、验证或发布稳定版 Lithe,则必须在修改发行说明、版本元数据、 -标签或发布工作流之前,额外加载 `.agents/skills/release-lithe/SKILL.md`。 - -如果任务涉及通过 Parallels 虚拟机来构建、运行、诊断 Windows 产品,或向其中 -传输文件,则必须在继续之前额外加载 -`.agents/skills/debug-windows-on-parallels/SKILL.md`。 - -## 测试进程生命周期与清理 - -除非用户明确要求保持进程运行,否则,任何为构建、测试、调试、预览或验证而 -启动的 Lithe 应用,都必须在任务或测试运行结束时关闭。清理所有子进程、辅助 -进程、临时应用实例及相关资源,并在交付工作之前确认没有残留的 Lithe 进程。 -重复检查期间不得启动重复的 Lithe 实例,也不得让测试构建的应用继续出现在 -用户的应用列表中。如果某个进程无法正常停止,必须明确报告,并在继续之前 -尽最大努力进行有界清理。 - -## 高性能 UI 交互与可调布局要求 - -涉及可拖拽分隔线、可调整面板、连续拖动、滚动或其他高频 UI 交互时,必须 -优先复用项目中已有的高性能布局容器和交互组件,不得为了快速实现而在业务 -父视图中直接堆叠自定义 `DragGesture`、逐事件写入多个 `@State` 或重复实现 -分隔线逻辑。 - -- macOS 的可调面板必须优先使用 `LitheSplitPaneView` 和 - `SplitHandleView`;如果确实无法复用,必须在变更说明中解释原因,并保持 - 相同的行为契约。 -- 拖拽处理必须使用稳定的坐标空间(连续拖动时优先使用全局坐标),避免因 - 分隔线自身移动导致坐标原点变化和拖拽跳动。 -- 高频拖拽事件必须经过节流、合并或死区过滤,不能在每个指针事件中触发 - 无必要的父视图重建;可变尺寸状态应尽量封装在局部布局容器内,避免拖动 - 使整个功能页面重新计算。 -- 必须提供明确的最小/最大尺寸和可用空间约束,保证相邻面板仍满足最低可用 - 宽度;窗口缩放、面板隐藏和重新出现时不得产生负尺寸或布局溢出。 -- 交互行为应与现有 Git、编辑器和工具窗口保持一致,包括悬停/拖拽高亮、 - 平台对应的调整光标、帮助文本和无障碍标签。 -- 如果尺寸需要跨刷新或重启保留,应通过现有布局持久化机制提交最终尺寸, - 不要在拖拽过程中持续写入持久化存储。 -- 新增或修改此类 UI 后,必须至少完成对应产品构建、`git diff --check` - 和相关边界检查;代码审查时应明确确认拖拽不会导致高频全页面重绘。 - -这些要求适用于 macOS 和 Windows:平台可使用各自的原生实现,但交互语义、 -性能目标、尺寸约束和可访问性要求必须保持一致。 +# Lithe Agent Entry Point + +Before any work in this repository, load and follow the `develop-lithe` skill +at `.agents/skills/develop-lithe/SKILL.md`. That skill is the single source of +truth for AI coding and verification rules, including the required Rust Core +comment standard. + +If a task creates, modifies, or reviews test code or test infrastructure, +additionally load `.agents/skills/write-stable-tests/SKILL.md` before +proceeding. That skill defines the mandatory bounded-wait, deterministic-time, +cleanup, and per-test timing rules for both macOS and Windows. + +If a task prepares, validates, or publishes a stable Lithe release, +additionally load `.agents/skills/release-lithe/SKILL.md` before changing +release notes, version metadata, tags, or release workflows. + +If the task involves building, running, diagnosing, or transferring files to the +Windows product through a Parallels guest VM, additionally load +`.agents/skills/debug-windows-on-parallels/SKILL.md` before proceeding. + +## Test Process Lifecycle and Cleanup + +Unless the user gives a specific instruction to keep a process running, any +Lithe application started for building, testing, debugging, previewing, or +verification must be shut down when the task or test run is complete. Clean up +all child processes, helper processes, temporary app instances, and related +resources, then verify that no Lithe processes remain before handing the work +back. Do not launch duplicate Lithe instances during repeated checks, and do +not leave test-built applications open in the user's application list. If a +process cannot be stopped cleanly, report it explicitly and make a bounded +best-effort cleanup before continuing. + +## High-Performance UI Interaction and Resizable Layout Requirements + +When working on draggable splitters, resizable panels, continuous dragging, +scrolling, or other high-frequency UI interactions, prioritize reusing the +project's existing high-performance layout containers and interaction +components. Do not quickly implement these behaviors by stacking custom +`DragGesture` handlers in a business parent view, writing to multiple `@State` +properties on every event, or duplicating splitter logic. + +- Resizable macOS panels must use `LitheSplitPaneView` and `SplitHandleView` + whenever possible. If they genuinely cannot be reused, explain why in the + change summary and preserve the same behavioral contract. +- Drag handling must use a stable coordinate space, preferably global + coordinates for continuous dragging, to prevent the moving splitter from + changing the coordinate origin and causing jumps. +- High-frequency drag events must be throttled, coalesced, or filtered with a + dead zone. Do not trigger unnecessary parent-view reconstruction on every + pointer event. Keep mutable size state in a local layout container whenever + possible so dragging does not recompute the entire feature page. +- Provide explicit minimum and maximum sizes and available-space constraints so + adjacent panels retain their minimum usable widths. Window resizing, panel + hiding, and panel restoration must not produce negative sizes or layout + overflow. +- Interaction behavior should match existing Git, editor, and tool windows, + including hover/drag highlighting, the platform-appropriate resize cursor, + help text, and accessibility labels. +- If sizes must persist across refreshes or restarts, commit the final size + through the existing layout-persistence mechanism rather than continuously + writing to persistent storage during dragging. +- After adding or modifying this type of UI, at minimum complete the relevant + product build, `git diff --check`, and boundary checks. During code review, + explicitly confirm that dragging does not cause high-frequency full-page + redraws. + +These requirements apply to both macOS and Windows. Each platform may use its +own native implementation, but interaction semantics, performance goals, size +constraints, and accessibility requirements must remain consistent. From 7ddcfaefaa3658fda31bd8205bea9b25c3825d96 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 3 Sep 2026 01:33:56 +0800 Subject: [PATCH 26/28] fix cross-platform worktree path matching --- rust/lithe-core/src/git/mod.rs | 20 ++++++++++++++++---- rust/lithe-core/src/tests/git.rs | 11 +++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 5ff0fe45c..21c19f3d2 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -4329,6 +4329,18 @@ fn list_worktrees(root: &str) -> Result, CoreError> { Ok(records) } +fn worktree_paths_match(left: &str, right: &str) -> bool { + let left_path = PathBuf::from(left); + let right_path = PathBuf::from(right); + if left_path == right_path { + return true; + } + match (left_path.canonicalize(), right_path.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + fn parse_worktree_record( fields: &[&str], is_primary: bool, @@ -4367,14 +4379,14 @@ fn parse_worktree_record( let lock = value_after_marker("locked"); let prunable = value_after_marker("prunable"); let reported_path = PathBuf::from(path); - let comparison_path = reported_path + let normalized_path = reported_path .canonicalize() .unwrap_or_else(|_| reported_path.clone()); Ok(GitWorktreeResponse { - path: path.to_string(), + path: normalized_path.to_string_lossy().to_string(), head, branch, - is_current: comparison_path == current_root, + is_current: normalized_path == current_root, is_primary, is_bare: fields.contains(&"bare"), is_detached: fields.contains(&"detached"), @@ -4426,7 +4438,7 @@ fn mutate_worktree(root: &str, request: &GitWriteRequest) -> Result Date: Thu, 3 Sep 2026 01:45:13 +0800 Subject: [PATCH 27/28] prevent stale worktree inspection updates --- .../Application/GitFeatureModel.swift | 6 +- .../LitheGitModuleTests/GitModuleTests.swift | 110 +++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 2f5250504..a711a62f3 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1689,7 +1689,6 @@ package final class GitFeatureModel: ObservableObject { let resolvedHistory = await history guard generation == worktreeInspectionRequestGeneration, !Task.isCancelled else { - gitWorktreeInspectionLoadState = .idle return } @@ -1758,10 +1757,13 @@ package final class GitFeatureModel: ObservableObject { inspection.worktreeID == worktree.id, inspection.hasMoreCommits, !Task.isCancelled else { return } + let generation = worktreeInspectionRequestGeneration let reference = gitReferences.first { $0.fullName == worktree.branch } let nextLimit = inspection.commits.count + 50 let history = await service.history(at: worktree.url, reference: reference, limit: nextLimit) - guard gitWorktreeInspection?.worktreeID == worktree.id else { return } + guard generation == worktreeInspectionRequestGeneration, + gitWorktreeInspection?.worktreeID == worktree.id, + !Task.isCancelled else { return } gitWorktreeInspection = GitWorktreeInspection( worktreeID: worktree.id, changes: inspection.changes, diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 950b702ff..81fa57ba3 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -982,6 +982,54 @@ struct GitModuleTests { #expect(feature.gitWorktreeInspection?.hasLoadedChanges == true) } + @Test + func staleWorktreeInspectionCannotClearNewLoadingState() async throws { + let oldWorktree = makeTestWorktree(path: "/workspace-old", branch: "feature/old") + let newWorktree = makeTestWorktree(path: "/workspace-new", branch: "feature/new") + let controller = GitHistoryLoadController(results: [ + GitHistorySnapshot( + references: [], + recentReferences: [], + commits: [makeTestCommit(hash: "old-history", subject: "Old")], + hasMore: false + ), + GitHistorySnapshot( + references: [], + recentReferences: [], + commits: [makeTestCommit(hash: "new-history", subject: "New")], + hasMore: false + ) + ]) + let feature = GitFeatureModel(service: GitService( + operations: TestGitOperations(historyController: controller) + )) + let oldInspection = Task { @MainActor in + await feature.inspectWorktree(oldWorktree) + } + defer { + oldInspection.cancel() + controller.releaseAll() + } + + try #require(await controller.waitUntilCallStarts(0)) + let newInspection = Task { @MainActor in + await feature.inspectWorktree(newWorktree) + } + defer { newInspection.cancel() } + try #require(await controller.waitUntilCallStarts(1)) + #expect(feature.gitWorktreeInspectionLoadState == .loading) + + controller.releaseCall(0) + try #require(await waitForGitTaskCompletion(oldInspection)) + #expect(feature.gitWorktreeInspectionLoadState == .loading) + + controller.releaseCall(1) + try #require(await waitForGitTaskCompletion(newInspection)) + #expect(feature.gitWorktreeInspectionLoadState == .ready) + #expect(feature.gitWorktreeInspection?.worktreeID == newWorktree.id) + #expect(!controller.didTimeOut) + } + @Test func newerWorktreeRefreshWinsWhenAnOlderRequestFinishesLast() async throws { let root = URL(fileURLWithPath: "/workspace") @@ -1427,6 +1475,58 @@ private final class GitModuleTestGate: @unchecked Sendable { } } +private final class GitHistoryLoadController: @unchecked Sendable { + private let lock = NSLock() + private let results: [GitHistorySnapshot] + private let startedGates: [GitModuleTestGate] + private let releaseGates: [GitModuleTestGate] + private var calls = 0 + private var timedOut = false + + init(results: [GitHistorySnapshot]) { + self.results = results + startedGates = results.map { _ in GitModuleTestGate() } + releaseGates = results.map { _ in GitModuleTestGate() } + } + + var didTimeOut: Bool { + lock.lock() + defer { lock.unlock() } + return timedOut + } + + func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { + let callIndex: Int + lock.lock() + callIndex = calls + calls += 1 + lock.unlock() + guard results.indices.contains(callIndex) else { return nil } + startedGates[callIndex].open() + guard releaseGates[callIndex].waitSynchronously() else { + lock.lock() + timedOut = true + lock.unlock() + return nil + } + return results[callIndex] + } + + func waitUntilCallStarts(_ index: Int) async -> Bool { + guard startedGates.indices.contains(index) else { return false } + return await startedGates[index].waitUntilOpen() + } + + func releaseCall(_ index: Int) { + guard releaseGates.indices.contains(index) else { return } + releaseGates[index].open() + } + + func releaseAll() { + releaseGates.forEach { $0.open() } + } +} + private final class GitWorktreeLoadController: @unchecked Sendable { private let lock = NSLock() private let results: [[GitWorktree]?] @@ -1681,6 +1781,7 @@ private struct TestGitOperations: GitOperations { private let comparisonDiffDocumentValue: DiffDocument? private let typedComparisonDiffDocumentValue: DiffDocument? private let historyValue: GitHistorySnapshot? + private let historyController: GitHistoryLoadController? private let snapshotGate: GitModuleTestGate? private let stageResult: GitProcessResult? private let runGate: TestGitRunGate? @@ -1692,6 +1793,7 @@ private struct TestGitOperations: GitOperations { comparisonValue: GitBranchComparison? = nil, typedComparisonValue: GitBranchComparison? = nil, historyValue: GitHistorySnapshot? = nil, + historyController: GitHistoryLoadController? = nil, filesValue: [GitCommitFile]? = nil, untrackedDiffDocumentValue: DiffDocument? = nil, comparisonDiffDocumentValue: DiffDocument? = nil, @@ -1706,6 +1808,7 @@ private struct TestGitOperations: GitOperations { self.comparisonValue = comparisonValue self.typedComparisonValue = typedComparisonValue self.historyValue = historyValue + self.historyController = historyController self.filesValue = filesValue self.untrackedDiffDocumentValue = untrackedDiffDocumentValue self.comparisonDiffDocumentValue = comparisonDiffDocumentValue @@ -1742,7 +1845,12 @@ private struct TestGitOperations: GitOperations { func comparisonDiffDocument(at rootURL: URL, reference: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { comparisonDiffDocumentValue } func comparisonDiffDocument(at rootURL: URL, reference: GitReference, targetReference: GitReference?, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { typedComparisonDiffDocumentValue } func applyPatch(_ patch: String, at rootURL: URL, mode: String) -> GitProcessResult? { nil } - func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { historyValue } + func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { + if let historyController { + return historyController.history(at: rootURL, reference: reference, limit: limit) + } + return historyValue + } func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { filesRecorder?.recordCall() if let filesGate { From 531ff41ac9433b99508a33a4ddd8d250bf05a2a4 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 3 Sep 2026 02:37:02 +0800 Subject: [PATCH 28/28] fix(macos): preserve deletion metadata in git results --- .../LitheGitModule/Services/GitService.swift | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 0df525bae..1a690e0e7 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -826,6 +826,8 @@ package struct GitService: Sendable { invocations: result?.invocations ?? [], operationErrorMessage: result?.operationErrorMessage, stashRestoreConflict: result?.stashRestoreConflict, + tagDeletion: result?.tagDeletion, + branchDeletion: result?.branchDeletion, warnings: result?.warnings ?? [] ) performanceLogger.record( @@ -835,19 +837,6 @@ package struct GitService: Sendable { arguments: commandResult.arguments, durationMilliseconds: elapsedMilliseconds(since: startedAt), succeeded: commandResult.succeeded - arguments: result?.arguments.isEmpty == false - ? result?.arguments ?? fallbackArguments - : fallbackArguments, - output: result?.output ?? "Rust Core Git operation failed", - standardOutput: result?.standardOutput, - standardError: result?.standardError, - exitCode: result?.exitCode ?? 1, - invocations: result?.invocations ?? [], - operationErrorMessage: result?.operationErrorMessage, - stashRestoreConflict: result?.stashRestoreConflict, - tagDeletion: result?.tagDeletion, - branchDeletion: result?.branchDeletion, - warnings: result?.warnings ?? [] ) ) return commandResult