From d5fbb862aa13ceb07abdc8b13da19769247f8808 Mon Sep 17 00:00:00 2001
From: lick <2188718831@qq.com>
Date: Wed, 4 Nov 2026 21:21:37 -0800
Subject: [PATCH 1/7] =?UTF-8?q?feat(core):=20#1662=20=E8=BD=A6=E9=81=93=20?=
=?UTF-8?q?A=20Git=20=E6=B5=81=E7=A8=8B=E4=B8=8B=E6=B2=89=EF=BC=88GIT-14/1?=
=?UTF-8?q?5/24/25/28/29=20=E7=9A=84=20Core=20=E8=A7=84=E5=88=92=E5=99=A8?=
=?UTF-8?q?=EF=BC=8CGIT-28=20=E4=B8=A4=E7=AB=AF=E6=8E=A5=E5=85=A5=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 Core 命令 git.normalizeChangeTargets / git.rebasePlanPreview / git.referenceTree / git.updatePlan / git.historyWindow,附共享 fixture 与测试
- 执行日志归约与统一保留预算进入 execution_events(GIT-25 Core 部分)
- GIT-28:macOS 与 Windows 的 Shelf、删除、回滚、提交目标去重改走 Core,删除两端旧实现
- 新增文案 git.changeTargets.prepareFailed(中英)
Co-Authored-By: Claude Opus 5.5
---
macos/Resources/en.lproj/Localizable.strings | 2 +
.../zh-Hans.lproj/Localizable.strings | 2 +
.../Lithe/Core/Rust/RustGitOperations.swift | 8 +
.../Application/GitFeatureModel.swift | 33 +-
.../LitheGitModule/Models/GitCorePlans.swift | 29 +
.../LitheGitModule/Services/GitService.swift | 19 +
rust/lithe-core/src/diagnostics/redaction.rs | 24 +-
rust/lithe-core/src/git/change_targets.rs | 140 +
rust/lithe-core/src/git/execution_events.rs | 171 +-
rust/lithe-core/src/git/history_window.rs | 274 +
rust/lithe-core/src/git/mod.rs | 9 +
rust/lithe-core/src/git/rebase_session.rs | 277 +-
rust/lithe-core/src/git/reference_tree.rs | 183 +
rust/lithe-core/src/git/update_plan.rs | 303 +
rust/lithe-core/src/protocol/command.rs | 15 +
rust/lithe-core/src/runtime/dispatcher.rs | 17 +
shared/fixtures/git/change-targets-v1.json | 69 +
shared/fixtures/git/execution-events-v1.json | 15 +-
shared/fixtures/git/history-window-v1.json | 280 +
.../fixtures/git/rebase-plan-preview-v1.json | 153 +
shared/fixtures/git/reference-tree-v1.json | 235 +
shared/fixtures/git/update-plan-v1.json | 456 +
shared/i18n/locales/en-US.json | 4121 +-
shared/i18n/locales/zh-CN.json | 4121 +-
shared/i18n/messages.json | 72197 ++++++++--------
.../git/api/git-change-targets-api.ts | 61 +
.../components/status/git-status-panel.tsx | 49 +-
.../git/utils/git-status-selection.test.ts | 18 -
.../git/utils/git-status-selection.ts | 13 -
.../utils/git-untracked-directories.test.ts | 21 +-
.../git/utils/git-untracked-directories.ts | 9 +-
31 files changed, 43009 insertions(+), 40315 deletions(-)
create mode 100644 macos/Sources/LitheGitModule/Models/GitCorePlans.swift
create mode 100644 rust/lithe-core/src/git/change_targets.rs
create mode 100644 rust/lithe-core/src/git/history_window.rs
create mode 100644 rust/lithe-core/src/git/reference_tree.rs
create mode 100644 rust/lithe-core/src/git/update_plan.rs
create mode 100644 shared/fixtures/git/change-targets-v1.json
create mode 100644 shared/fixtures/git/history-window-v1.json
create mode 100644 shared/fixtures/git/rebase-plan-preview-v1.json
create mode 100644 shared/fixtures/git/reference-tree-v1.json
create mode 100644 shared/fixtures/git/update-plan-v1.json
create mode 100644 windows/tauri/src/features/git/api/git-change-targets-api.ts
diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings
index a0edb62280..eea4d341ff 100644
--- a/macos/Resources/en.lproj/Localizable.strings
+++ b/macos/Resources/en.lproj/Localizable.strings
@@ -1098,6 +1098,7 @@
"Could not open the remote folder: %@" = "Could not open the remote folder: %@";
"Could not prepare diagnostic logs. Try again." = "Could not prepare diagnostic logs. Try again.";
"Could not prepare the run configuration: %@" = "Could not prepare the run configuration: %@";
+"Could not prepare the selected changes." = "Could not prepare the selected changes.";
"Could not read %@." = "Could not read %@.";
"Could not read %@: %@" = "Could not read %@: %@";
"Could not read project indexing settings. Try reopening the project." = "Could not read project indexing settings. Try reopening the project.";
@@ -5214,6 +5215,7 @@
"from JAVA_HOME" = "from JAVA_HOME";
"generated main" = "generated main";
"generated test" = "generated test";
+"git.changeTargets.prepareFailed" = "Could not prepare the selected changes.";
"git.changelists.invalidName" = "Use a unique, nonempty name of at most 100 characters.";
"git.diff.repositoryPreviewEmptyTitle" = "Repository Diff";
"git.diff.repositoryPreviewTitle" = "Repository Diff: %1$@";
diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings
index aadbe2cb9f..a005bd77c6 100644
--- a/macos/Resources/zh-Hans.lproj/Localizable.strings
+++ b/macos/Resources/zh-Hans.lproj/Localizable.strings
@@ -1098,6 +1098,7 @@
"Could not open the remote folder: %@" = "无法打开远程文件夹:%@";
"Could not prepare diagnostic logs. Try again." = "无法准备诊断日志,请重试。";
"Could not prepare the run configuration: %@" = "无法准备运行配置:%@";
+"Could not prepare the selected changes." = "无法准备所选更改。";
"Could not read %@." = "无法读取 %@。";
"Could not read %@: %@" = "无法读取 %@:%@";
"Could not read project indexing settings. Try reopening the project." = "无法读取项目索引设置,请重新打开项目后重试。";
@@ -5214,6 +5215,7 @@
"from JAVA_HOME" = "来自 JAVA_HOME";
"generated main" = "生成的主代码";
"generated test" = "生成的测试代码";
+"git.changeTargets.prepareFailed" = "无法准备所选更改。";
"git.changelists.invalidName" = "请输入不重名、非空且不超过 100 个字符的名称。";
"git.diff.repositoryPreviewEmptyTitle" = "仓库差异";
"git.diff.repositoryPreviewTitle" = "仓库差异: %1$@";
diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
index 0ae0112072..f7d0f25b79 100644
--- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
+++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
@@ -806,6 +806,14 @@ struct RustGitOperations: GitOperations, Sendable {
core.cancel(operationID: operationID)
}
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request
+ ) -> Response? {
+ let result: Result = core.executeResult(command: command.rawValue, payload: request)
+ return try? result.get()
+ }
+
func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? {
core.gitCommitFiles(at: rootURL, commit: commit.hash)?.files.map { file in
GitCommitFile(status: file.status, path: file.path)
diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
index c67b25b64b..74be963d2a 100644
--- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
+++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
@@ -1182,17 +1182,21 @@ package final class GitFeatureModel: ObservableObject {
}
}
- /// Drops changes below a directory summary that is in the same set.
+ /// The changes one operation acts on (`git.normalizeChangeTargets`).
///
- /// Acting on the summary already acts on them. Acting again would write
- /// each file twice into a shelf patch, or fail once the summary's files
- /// have been cleaned.
- private func coveringUntrackedDirectoryContents(_ changes: [GitChange]) -> [GitChange] {
- let summaries = changes.filter(\.isUntrackedDirectory)
- guard !summaries.isEmpty else { return changes }
- return changes.filter { change in
- !summaries.contains { $0.containsUntrackedDescendant(change) }
+ /// Core drops changes listed below a selected untracked directory summary
+ /// of the same repository: acting on the summary already acts on them, and
+ /// acting again would write a file twice into a shelf patch or fail once the
+ /// summary's files are gone. `nil` means Core rejected the selection; the
+ /// caller reports it instead of acting on an unnormalized set.
+ private func operationTargets(_ changes: [GitChange]) async -> [GitChange]? {
+ guard !changes.isEmpty else { return [] }
+ let plan: GitChangeTargetsPlan? = await service.corePlan(.normalizeChangeTargets, GitChangeTargetsRequest(changes))
+ guard let plan, plan.indices.allSatisfy(changes.indices.contains) else {
+ notifyFailure(L10n.string("Could not prepare the selected changes."), reason: "operation_failed")
+ return nil
}
+ return plan.indices.map { changes[$0] }
}
/// True for a change listed only because its untracked summary is expanded.
@@ -1873,8 +1877,7 @@ package final class GitFeatureModel: ObservableObject {
}
package func discardChanges(_ changes: [GitChange]) async {
- let changes = coveringUntrackedDirectoryContents(changes)
- guard !changes.isEmpty else { return }
+ guard let changes = await operationTargets(changes), !changes.isEmpty else { return }
_ = await withGitChangeOperation(changes) {
var results: [GitService.CommandResult] = []
for change in changes {
@@ -1927,7 +1930,7 @@ package final class GitFeatureModel: ObservableObject {
}
let contextID = rollbackContextID
let workspace = rollbackWorkspaceURL
- let changes = coveringUntrackedDirectoryContents(selection.includedChanges)
+ guard let changes = await operationTargets(selection.includedChanges) else { return }
activeRollbackID = contextID
isRollingBackChanges = true
defer {
@@ -2199,7 +2202,7 @@ package final class GitFeatureModel: ObservableObject {
guard await activateChangelist(targetList), canPrepareCommitFiles(changes) else { return false }
let generation = workspaceCommitGeneration
let workspace = workspaceURLProvider?()?.standardizedFileURL
- let selected = coveringUntrackedDirectoryContents(changes)
+ guard let selected = await operationTargets(changes) else { return false }
let roots = Set(availableRepositoryRoots.map(\.standardizedFileURL) + changes.map { $0.repositoryRoot.standardizedFileURL }).sorted { $0.path < $1.path }
let selectedByRoot = Dictionary(grouping: selected) { $0.repositoryRoot.standardizedFileURL }
let request = GitCommitSelectionRequest(repositories: roots.map {
@@ -3304,12 +3307,12 @@ package final class GitFeatureModel: ObservableObject {
inspection.hasLoadedChanges else { return }
// Resolve captured rows against the latest inspection. Neither a stale
// confirmation nor a foreign GitChange may redirect a worktree action.
- let targets = coveringUntrackedDirectoryContents(inspection.changes.filter { current in
+ guard let targets = await operationTargets(inspection.changes.filter { current in
current.repositoryRoot.standardizedFileURL == worktree.url.standardizedFileURL
&& changes.contains(current)
&& (staged.map { current.canToggleStaging && ($0 ? (!current.isStaged || current.hasWorkingTreeChange) : current.isStaged) }
?? current.hasWorkingTreeChange)
- })
+ }) else { return }
guard !targets.isEmpty else { return }
let mutationID = UUID()
worktreeChangesMutationID = mutationID
diff --git a/macos/Sources/LitheGitModule/Models/GitCorePlans.swift b/macos/Sources/LitheGitModule/Models/GitCorePlans.swift
new file mode 100644
index 0000000000..d02bc60990
--- /dev/null
+++ b/macos/Sources/LitheGitModule/Models/GitCorePlans.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+/// Pure Core Git planners. They never start Git or read a repository; the app
+/// collects facts, Core decides, and the app executes and presents the result.
+package enum GitCorePlanCommand: String, Sendable {
+ case normalizeChangeTargets = "git.normalizeChangeTargets"
+}
+
+// MARK: - git.normalizeChangeTargets
+
+package struct GitChangeTargetsRequest: Encodable, Sendable {
+ package struct Target: Encodable, Sendable {
+ package let root: String
+ package let path: String
+ package let untrackedDirectory: Bool
+ }
+ package let targets: [Target]
+
+ package init(_ changes: [GitChange]) {
+ targets = changes.map {
+ Target(root: $0.repositoryRoot.standardizedFileURL.path, path: $0.path,
+ untrackedDirectory: $0.isUntrackedDirectory)
+ }
+ }
+}
+
+package struct GitChangeTargetsPlan: Decodable, Sendable {
+ package let indices: [Int]
+}
diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift
index 1e45e931ad..48ebce8162 100644
--- a/macos/Sources/LitheGitModule/Services/GitService.swift
+++ b/macos/Sources/LitheGitModule/Services/GitService.swift
@@ -123,6 +123,12 @@ package protocol GitOperations: Sendable {
operationID: String
) -> GitHistoryLocateResult?
func closeHistoryCursor(at rootURL: URL, cursor: String) -> Bool
+ /// Runs one pure Core planner. Planners never start Git or read a
+ /// repository; `nil` means Core rejected the request.
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request
+ ) -> Response?
func cancel(operationID: String) -> Bool
func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]?
@@ -240,6 +246,10 @@ package protocol GitOperations: Sendable {
}
package extension GitOperations {
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request
+ ) -> Response? { nil }
func workspaceStatusPlan(roots: [String], previousRoots: [String], outcomes: [GitWorkspaceStatusOutcome]) -> GitWorkspaceStatusPlan? { nil }
func resolveFileContext(fileURL: URL, relativeTo rootURL: URL) -> GitFileContext? { nil }
func changelistProjection(_ request: GitChangelistCoreRequest) -> GitChangelistCoreProjection? { nil }
@@ -1004,6 +1014,15 @@ package struct GitService: Sendable {
}
}
+ /// Runs a pure Core planner off the main actor.
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request,
+ operationName: String = #function
+ ) async -> Response? {
+ await read(operationName: operationName) { $0.corePlan(command, request) }
+ }
+
func branchNamePreview(
rawName: String,
requireName: Bool,
diff --git a/rust/lithe-core/src/diagnostics/redaction.rs b/rust/lithe-core/src/diagnostics/redaction.rs
index b7837f304b..c8fd063591 100644
--- a/rust/lithe-core/src/diagnostics/redaction.rs
+++ b/rust/lithe-core/src/diagnostics/redaction.rs
@@ -34,8 +34,10 @@ static SENSITIVE_ASSIGNMENT: LazyLock = LazyLock::new(|| {
.expect("valid regex")
});
+// Query names hosts once redacted on their own before displaying Git output;
+// Core is now the only redaction pass for console events.
static SENSITIVE_QUERY_PARAMETER: LazyLock = LazyLock::new(|| {
- Regex::new(r"(?i)([?&](?:access_token|api_key|token|key|auth))=[^\s]+").expect("valid regex")
+ Regex::new(r"(?i)([?&](?:access_token|api_key|apikey|token|key|auth|authorization|client_secret|password|passwd|secret))=[^\s]+").expect("valid regex")
});
static CREDENTIAL_SHAPE: LazyLock = LazyLock::new(|| {
@@ -123,6 +125,26 @@ mod tests {
);
}
+ /// Git console hosts no longer redact event text themselves; every query
+ /// name they used to scrub must be covered here.
+ #[test]
+ fn redacts_every_query_credential_hosts_used_to_scrub() {
+ for name in [
+ "client_secret",
+ "passwd",
+ "apikey",
+ "authorization",
+ "auth",
+ "token",
+ ] {
+ assert_eq!(
+ redact(&format!("https://example.com/r.git?x=1&{name}=s3cr3t")),
+ format!("https://example.com/r.git?x=1&{name}="),
+ "{name}"
+ );
+ }
+ }
+
#[test]
fn redacts_sensitive_key_value_pairs() {
assert_eq!(redact("password=hunter2"), "password=");
diff --git a/rust/lithe-core/src/git/change_targets.rs b/rust/lithe-core/src/git/change_targets.rs
new file mode 100644
index 0000000000..301f3f8a51
--- /dev/null
+++ b/rust/lithe-core/src/git/change_targets.rs
@@ -0,0 +1,140 @@
+//! Normalizes the change targets one Git operation acts on.
+//!
+//! A wholly untracked directory can be reported as one summary (`dir/`) whose
+//! contents the user may list on demand. Selecting the summary together with
+//! some of its listed files must act on each file exactly once: a shelf patch
+//! would otherwise record the file twice, and a delete or rollback would fail
+//! once the summary's files are gone. Hosts send the selection they captured;
+//! Core decides which entries remain operation targets.
+
+use crate::protocol::{CoreError, ErrorCode};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashSet;
+
+/// Upper bound for one selection; larger requests are host bugs, not user input.
+const MAX_TARGETS: usize = 100_000;
+
+/// One selected change identity in the order the host captured it.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct Target {
+ /// Host-normalized repository root that owns `path`; only equality is used.
+ root: String,
+ /// Repository-relative path. A summary directory ends with `/`.
+ path: String,
+ /// True for a Core summary of a wholly untracked directory.
+ #[serde(default)]
+ untracked_directory: bool,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct Request {
+ targets: Vec,
+}
+
+/// Positions of the retained targets, ascending, so hosts keep their own rows.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Response {
+ indices: Vec,
+}
+
+fn invalid(message: &str) -> CoreError {
+ CoreError::new(ErrorCode::InvalidRequest, message)
+}
+
+/// Handles `git.normalizeChangeTargets`.
+///
+/// A target is dropped when a summary of the same repository is a strict
+/// ancestor of its path. Everything else, including duplicates the host chose
+/// to send, keeps its position; acting on a summary already acts on the
+/// listed files below it.
+pub(crate) fn command(payload: Value) -> Result {
+ let request: Request = serde_json::from_value(payload).map_err(|error| {
+ invalid("Invalid Git change target request").with_details(error.to_string())
+ })?;
+ if request.targets.len() > MAX_TARGETS {
+ return Err(invalid("Too many Git change targets"));
+ }
+ if request
+ .targets
+ .iter()
+ .any(|target| target.root.is_empty() || target.path.is_empty())
+ {
+ return Err(invalid("Git change targets need a root and a path"));
+ }
+ if request
+ .targets
+ .iter()
+ .any(|target| target.untracked_directory && !target.path.ends_with('/'))
+ {
+ return Err(invalid("An untracked directory summary must end with /"));
+ }
+ let summaries = request
+ .targets
+ .iter()
+ .filter(|target| target.untracked_directory)
+ .map(|target| (target.root.as_str(), target.path.as_str()))
+ .collect::>();
+ let indices = request
+ .targets
+ .iter()
+ .enumerate()
+ .filter(|(_, target)| !below_summary(target, &summaries))
+ .map(|(index, _)| index)
+ .collect();
+ Ok(serde_json::to_value(Response { indices }).expect("change target indices encode"))
+}
+
+/// Walks the path's own ancestors, so the cost is bounded by its depth rather
+/// than by the number of summaries in the selection.
+fn below_summary(target: &Target, summaries: &HashSet<(&str, &str)>) -> bool {
+ target
+ .path
+ .match_indices('/')
+ .map(|(index, _)| &target.path[..=index])
+ .any(|prefix| {
+ prefix.len() < target.path.len() && summaries.contains(&(target.root.as_str(), prefix))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::{json, Value};
+
+ fn call(payload: Value) -> Value {
+ serde_json::from_str(&crate::execute_json(
+ &json!({"id":"targets", "command":"git.normalizeChangeTargets", "payload":payload})
+ .to_string(),
+ ))
+ .unwrap()
+ }
+
+ #[test]
+ fn shared_fixture_cases_keep_only_operation_targets() {
+ let fixture: Value = serde_json::from_str(include_str!(
+ "../../../../shared/fixtures/git/change-targets-v1.json"
+ ))
+ .unwrap();
+ for case in fixture["cases"].as_array().unwrap() {
+ let response = call(case["request"].clone());
+ assert_eq!(response["ok"], true, "{}: {response}", case["name"]);
+ assert_eq!(
+ response["data"]["indices"], case["indices"],
+ "{}",
+ case["name"]
+ );
+ }
+ for case in fixture["invalid"].as_array().unwrap() {
+ let response = call(case["request"].clone());
+ assert_eq!(response["ok"], false, "{}", case["name"]);
+ assert_eq!(
+ response["error"]["code"], "invalid_request",
+ "{}",
+ case["name"]
+ );
+ }
+ }
+}
diff --git a/rust/lithe-core/src/git/execution_events.rs b/rust/lithe-core/src/git/execution_events.rs
index 9ba45c460e..d82c35e235 100644
--- a/rust/lithe-core/src/git/execution_events.rs
+++ b/rust/lithe-core/src/git/execution_events.rs
@@ -2,6 +2,13 @@
//!
//! Events contain sanitized diagnostics, never command input or environment.
//! Native process ownership belongs to the `lithe-git-host` adapter.
+//!
+//! Hosts keep a console journal of these events. The journal's record states
+//! and retention budget are part of this contract: `requestStarted` carries
+//! [`JOURNAL_RETENTION`], `finished` carries the record's final state, and
+//! `requestFinished` names the records that never confirmed an exit and
+//! whether a failure needs a record of its own. Hosts store, lock, and
+//! throttle; they do not derive states or redact again.
use crate::protocol::CoreError;
use serde::Serialize;
@@ -12,6 +19,28 @@ use std::time::Instant;
const MAX_LINE_BYTES: usize = 16 * 1024;
const MAX_DIAGNOSTIC_BYTES: usize = 512 * 1024;
const MAX_OUTPUT_EVENTS: usize = 4096;
+
+/// Console journal retention shared by every host, in host string length units.
+#[derive(Clone, Copy, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct JournalRetention {
+ /// Records kept across all requests; the oldest are dropped first.
+ pub(crate) max_records: usize,
+ /// Output kept per record; earlier lines are dropped first.
+ pub(crate) max_record_output_characters: usize,
+ /// Output lines kept per record, including empty lines.
+ pub(crate) max_record_output_lines: usize,
+ /// Output kept across all records before the oldest record is dropped.
+ pub(crate) max_total_output_characters: usize,
+}
+
+/// One budget for both products, chosen for the lower resident memory.
+pub(crate) const JOURNAL_RETENTION: JournalRetention = JournalRetention {
+ max_records: 200,
+ max_record_output_characters: 32_768,
+ max_record_output_lines: 2_000,
+ max_total_output_characters: 1_048_576,
+};
/// A serialized event consumer, invoked synchronously during its owning request.
pub type EventSink = Arc;
@@ -32,9 +61,16 @@ struct Event<'a> {
)]
/// Process start is evidence of a spawned child; failure may have no exit code.
enum Kind<'a> {
- RequestStarted,
+ RequestStarted {
+ retention: JournalRetention,
+ },
RequestFinished {
error: Option,
+ /// Records of this request still running; hosts mark them `unconfirmed`.
+ unconfirmed_invocations: Vec,
+ /// The failure happened before any Git process of this request started,
+ /// so it is shown as its own record instead of on an executed command.
+ failure_record: bool,
},
Started {
source: super::console::Source,
@@ -61,6 +97,8 @@ enum Kind<'a> {
exit_code: Option,
duration_milliseconds: u128,
error: Option<&'a CoreError>,
+ /// `completed` with an exit code; `unconfirmed` when the exit was not observed.
+ state: &'static str,
},
}
@@ -71,6 +109,10 @@ struct Context {
next_invocation: u64,
/// Transfer summaries remain attached to the transfer despite subsequent inspection queries.
last_transfer: Option,
+ /// Processes started by the current request.
+ request_invocations: usize,
+ /// Started processes of the current request without a `finished` event.
+ open_invocations: std::collections::BTreeSet,
}
thread_local! { static CURRENT: RefCell
))}
- {invalidPlan ? (
+ {plan?.issue ? (
{t("git.rebasePlan.invalidPlan")}
@@ -368,17 +362,17 @@ function GitRebaseDialog({
{step.action === "reword" || step.action === "squash" ? (
updateStep(index, { message })}
disabled={status === "working"}
/>
) : (
- {splitGitCommitMessage(plan.messages.get(step.hash) ?? "").title}
+ {splitGitCommitMessage(messages.get(step.hash) ?? "").title}
- {plan.messages.get(step.hash)}
+ {messages.get(step.hash)}
)}
@@ -505,7 +499,7 @@ function GitRebaseDialog({
disabled={
status !== "ready" || !preview.allowed || !preview.expectedState || invalidPlan
}
- onClick={() => void run(() => startGitRebase(request.repoPath, preview, plan.steps))}
+ onClick={() => void run(() => startGitRebase(request.repoPath, preview, plan?.startSteps ?? []))}
>
{t("git.rebasePlan.start")}
diff --git a/windows/tauri/src/features/git/utils/git-rebase-plan.ts b/windows/tauri/src/features/git/utils/git-rebase-plan.ts
index 079b52f90b..d438334398 100644
--- a/windows/tauri/src/features/git/utils/git-rebase-plan.ts
+++ b/windows/tauri/src/features/git/utils/git-rebase-plan.ts
@@ -1,42 +1,59 @@
+import { useEffect, useState } from "react";
+import { invoke } from "@/platform/tauri-core";
import type { GitHistoryRewriteCommit } from "../types/git-history-rewrite.types";
import type { GitRebaseStep } from "../types/git-rebase.types";
-/** Plan messages are explicit user edits; predicted squash text stays presentation-only. */
-export function prepareGitRebasePlan(
- steps: GitRebaseStep[],
- commits: GitHistoryRewriteCommit[],
-): { steps: GitRebaseStep[]; messages: Map } {
- const originals = new Map(commits.map((commit) => [commit.hash, commit.message]));
- const messages = new Map();
- let combined = "";
- const prepared = steps.map((step): GitRebaseStep => {
- const original = originals.get(step.hash) ?? "";
- let displayed = original;
- switch (step.action) {
- case "pick":
- case "edit":
- combined = original;
- break;
- case "reword":
- combined = displayed = step.message ?? original;
- break;
- case "squash":
- combined = displayed = step.message ?? `${combined.replace(/\n+$/, "")}\n\n${original}`;
- break;
- case "fixup":
- case "drop":
- break;
- }
- messages.set(step.hash, displayed);
- return {
- hash: step.hash,
- action: step.action,
- ...(step.action === "reword"
- ? { message: displayed }
- : step.action === "squash" && step.message !== undefined
- ? { message: step.message }
- : {}),
- };
+/** What the plan editor presents and submits for one draft (`git.rebasePlanPreview`). */
+export interface GitRebasePlanPreview {
+ /** First problem the user must fix; `null` when the plan can start. */
+ issue: {
+ code: "missingPrecedingCommit" | "emptyRewordMessage" | "emptySquashMessage" | "invalidMessage";
+ hash: string | null;
+ } | null;
+ /** Draft rows with the message the editor shows for each. */
+ steps: Array<{ hash: string; action: GitRebaseStep["action"]; message: string }>;
+ /** Exact steps for `git.rebaseStart`. */
+ startSteps: GitRebaseStep[];
+ outputCommitCount: number;
+}
+
+/**
+ * Validates a rebase draft and derives its messages in Core, with the rules the
+ * start command applies. Plan messages are explicit user edits; predicted squash
+ * text stays presentation-only.
+ */
+export function loadGitRebasePlanPreview(
+ steps: readonly GitRebaseStep[],
+ commits: readonly GitHistoryRewriteCommit[],
+): Promise {
+ return invoke("git.rebasePlanPreview", {
+ commits: commits.map(({ hash, message }) => ({ hash, message })),
+ steps,
});
- return { steps: prepared, messages };
+}
+
+/** Core's preview for the current draft; `null` while it loads, after a rejected draft, or without commits. */
+export function useGitRebasePlanPreview(
+ steps: readonly GitRebaseStep[],
+ commits: readonly GitHistoryRewriteCommit[] | undefined,
+): GitRebasePlanPreview | null {
+ const [snapshot, setSnapshot] = useState<{
+ steps: readonly GitRebaseStep[];
+ plan: GitRebasePlanPreview;
+ } | null>(null);
+ useEffect(() => {
+ if (!commits || commits.length === 0) return;
+ let current = true;
+ loadGitRebasePlanPreview(steps, commits)
+ .then((plan) => {
+ if (current) setSnapshot({ steps, plan });
+ })
+ .catch((error) => {
+ if (current) console.error("Failed to preview the rebase plan:", error);
+ });
+ return () => {
+ current = false;
+ };
+ }, [steps, commits]);
+ return snapshot?.steps === steps ? snapshot.plan : null;
}
From a44ed8207099d10dee44acfad75cbe61a2fcea81 Mon Sep 17 00:00:00 2001
From: lick <2188718831@qq.com>
Date: Wed, 4 Nov 2026 22:38:23 -0800
Subject: [PATCH 6/7] =?UTF-8?q?feat(git):=20#1662=20GIT-25=20Git=20?=
=?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=97=A5=E5=BF=97=E7=8A=B6=E6=80=81=E3=80=81?=
=?UTF-8?q?=E4=BF=9D=E7=95=99=E9=A2=84=E7=AE=97=E4=B8=8E=E8=84=B1=E6=95=8F?=
=?UTF-8?q?=E7=94=B1=20Core=20=E4=BA=8B=E4=BB=B6=E6=90=BA=E5=B8=A6?=
=?UTF-8?q?=EF=BC=8C=E4=B8=A4=E7=AB=AF=E4=B8=8D=E5=86=8D=E5=90=84=E8=87=AA?=
=?UTF-8?q?=E5=BD=92=E7=BA=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
.../Models/GitExecutionJournal.swift | 12 ++-
.../Models/GitExecutionModels.swift | 79 ++++++++++++-------
.../GitExecutionTests.swift | 45 +++++++----
.../features/git-history.json | 2 +-
.../services/git-execution-journal.test.ts | 37 +++++----
.../git/services/git-execution-journal.ts | 53 +++++++------
.../src/platform/git-execution-events.ts | 15 ++++
7 files changed, 157 insertions(+), 86 deletions(-)
diff --git a/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift b/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift
index 8bd5d671ca..89d00e34b6 100644
--- a/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift
+++ b/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift
@@ -16,6 +16,8 @@ package final class GitExecutionJournal: @unchecked Sendable {
private var entryIDs: [String: Set] = [:]
private var hidden: Set = []
private var omittedHistory = false
+ /// Core's budget from the latest `requestStarted`.
+ private var retention: GitJournalRetention?
package init() {}
@@ -48,8 +50,9 @@ package final class GitExecutionJournal: @unchecked Sendable {
entries.append(entry)
}
}
- if entries.count > 200 { omittedHistory = true; entries.removeFirst(entries.count - 200) }
- while entries.count > 1 && entries.reduce(0, { $0 + $1.output.count }) > 1_048_576 {
+ guard let retention else { return }
+ if entries.count > retention.maxRecords { omittedHistory = true; entries.removeFirst(entries.count - retention.maxRecords) }
+ while entries.count > 1 && entries.reduce(0, { $0 + $1.output.count }) > retention.maxTotalOutputCharacters {
omittedHistory = true
entries.removeFirst()
}
@@ -63,6 +66,7 @@ package final class GitExecutionJournal: @unchecked Sendable {
private func receiveLocked(_ event: GitExecutionEvent, root: URL?) -> Bool {
let operationID = event.operationId
if event.type == "requestStarted" {
+ if let budget = event.retention { retention = budget }
activeRequests.insert(operationID)
requestRoots[operationID] = root ?? event.workingDirectory.flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) }
return false
@@ -78,7 +82,7 @@ package final class GitExecutionJournal: @unchecked Sendable {
if hidden.remove(operationID) != nil { return wasRunning }
guard let context = contexts[operationID] else {
// A request cancelled before any command ran (a newer request superseded it) is not a failure.
- guard let error = event.error, !error.isCancellation,
+ guard let error = event.error, event.failureRecord == true,
let root = requestRoots[operationID] ?? root else { return wasRunning }
retain([GitConsoleEntry(workingDirectory: root, arguments: [], output: "", exitCode: -1,
state: .unconfirmed, operationErrorMessage: [error.message, error.details].compactMap { $0 }.joined(separator: "\n"))])
@@ -100,7 +104,7 @@ package final class GitExecutionJournal: @unchecked Sendable {
}
// Empty bookkeeping requests are not invented console commands.
guard event.type == "started" || contexts[operationID] != nil else { return false }
- let context = contexts[operationID] ?? GitExecutionContext(operationID: operationID)
+ let context = contexts[operationID] ?? GitExecutionContext(operationID: operationID, retention: retention)
contexts[operationID] = context
context.receive(event)
omittedHistory = omittedHistory || context.hasOmittedHistory
diff --git a/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift b/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift
index 5a039eaea8..ce5c1919fe 100644
--- a/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift
+++ b/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift
@@ -35,6 +35,14 @@ package struct GitExecutionEvent: Decodable, Sendable {
package var exitCode: Int32?
package var durationMilliseconds: Int?
package var error: Failure?
+ /// `requestStarted`: the console journal budget Core defines for every host.
+ package var retention: GitJournalRetention?
+ /// `finished`: the record's final state, decided by Core.
+ package var state: String?
+ /// `requestFinished`: records of this request whose exit was never observed.
+ package var unconfirmedInvocations: [Int]?
+ /// `requestFinished`: the failure needs its own record because no Git process started.
+ package var failureRecord: Bool?
package struct Failure: Decodable, Sendable {
package let code: String
@@ -46,6 +54,14 @@ package struct GitExecutionEvent: Decodable, Sendable {
}
}
+/// Console journal budget from Core, in character counts.
+package struct GitJournalRetention: Decodable, Equatable, Sendable {
+ package let maxRecords: Int
+ package let maxRecordOutputCharacters: Int
+ package let maxRecordOutputLines: Int
+ package let maxTotalOutputCharacters: Int
+}
+
/// The lock protects synchronous bridge delivery and cancellation across the
/// worker/UI boundary. Only bounded snapshots cross onto the main actor.
package final class GitExecutionContext: @unchecked Sendable {
@@ -62,10 +78,8 @@ package final class GitExecutionContext: @unchecked Sendable {
private var requestRoot: URL?
private var requestInvocationID: UUID?
private var omittedHistory = false
- private static let maxRecords = 200
- private static let maxStreamCharacters = 32_768
- private static let maxOutputLines = 2_000
- private static let maxTotalCharacters = 1_048_576
+ /// Core's budget, learned from `requestStarted`; nothing is trimmed before it arrives.
+ private var retention: GitJournalRetention?
private struct Record {
let invocationID: Int
@@ -93,14 +107,19 @@ package final class GitExecutionContext: @unchecked Sendable {
var duration: Int?
var error: String?
- mutating func appendOutput(_ text: String, stream: GitConsoleOutputStream) {
+ mutating func appendOutput(_ text: String, stream: GitConsoleOutputStream, retention: GitJournalRetention?) {
// Bound both text storage and rendered rows, including empty lines.
- let retained = String(text.suffix(GitExecutionContext.maxStreamCharacters - 1))
+ guard let retention else {
+ lines.append(GitConsoleOutputLine(stream: stream, text: text))
+ lineCharacters += text.count + 1
+ return
+ }
+ let retained = String(text.suffix(retention.maxRecordOutputCharacters - 1))
lines.append(GitConsoleOutputLine(stream: stream, text: retained))
lineCharacters += retained.count + 1
truncated = truncated || retained.count < text.count
- while lines.count > 1 && (lineCharacters > GitExecutionContext.maxStreamCharacters
- || lines.count > GitExecutionContext.maxOutputLines) {
+ while lines.count > 1 && (lineCharacters > retention.maxRecordOutputCharacters
+ || lines.count > retention.maxRecordOutputLines) {
lineCharacters -= lines.removeFirst().text.count + 1
truncated = true
}
@@ -118,7 +137,8 @@ package final class GitExecutionContext: @unchecked Sendable {
}
package init(operationID: String = UUID().uuidString, source: GitExecutionSource = .unknown,
- action: UserAction? = UserActionContext.current) {
+ action: UserAction? = UserActionContext.current, retention: GitJournalRetention? = nil) {
+ self.retention = retention
self.operationID = operationID
self.source = source
self.action = action
@@ -143,6 +163,7 @@ package final class GitExecutionContext: @unchecked Sendable {
/// Called with the lock held so each event publishes an atomic snapshot.
private func receiveLocked(_ event: GitExecutionEvent, at root: URL?) {
if event.type == "requestStarted" {
+ if let retention = event.retention { self.retention = retention }
requestRoot = root ?? event.workingDirectory.map { URL(fileURLWithPath: $0) }
requestInvocationID = nil
return
@@ -160,7 +181,8 @@ package final class GitExecutionContext: @unchecked Sendable {
if event.type == "requestFinished" {
defer { requestRoot = nil; requestInvocationID = nil }
challenges.removeAll()
- for index in records.indices where records[index].state == .running {
+ let unconfirmed = Set(event.unconfirmedInvocations ?? [])
+ for index in records.indices where unconfirmed.contains(records[index].invocationID) {
records[index].state = .unconfirmed
dirty = true
}
@@ -169,10 +191,10 @@ package final class GitExecutionContext: @unchecked Sendable {
if let index = records.lastIndex(where: { $0.id == requestInvocationID }) {
records[index].error = message
dirty = true
- } else if let root = requestRoot, !error.isCancellation {
+ } else if let root = requestRoot, event.failureRecord == true {
// A context can span several Core requests. Keep a preflight
// failure separate from an earlier request's executed command.
- // A cancellation before any command ran has nothing to report.
+ // Core decides which failures need a record of their own.
appendRecord(Record(invocationID: -1, root: root, arguments: [],
source: source, state: .unconfirmed, exitCode: -1, error: message))
}
@@ -198,30 +220,33 @@ package final class GitExecutionContext: @unchecked Sendable {
switch event.type {
case "output":
records[index].phase = event.progressDetails ?? records[index].phase
- let text = GitConsoleRedactor.redact(event.text ?? "")
+ // Core redacts event text once; the host never scrubs it again.
+ let text = event.text ?? ""
if event.progress == true {
records[index].progress = text
} else {
if event.stream == "stderr" { records[index].stderr += text + "\n" }
else { records[index].stdout += text + "\n" }
- records[index].appendOutput(text, stream: event.stream == "stderr" ? .standardError : .standardOutput)
+ records[index].appendOutput(text, stream: event.stream == "stderr" ? .standardError : .standardOutput, retention: retention)
records[index].progress = nil
}
records[index].truncated = records[index].truncated || event.truncated == true
- if records[index].stdout.count > Self.maxStreamCharacters {
- records[index].stdout = String(records[index].stdout.suffix(Self.maxStreamCharacters))
- records[index].truncated = true
- }
- if records[index].stderr.count > Self.maxStreamCharacters {
- records[index].stderr = String(records[index].stderr.suffix(Self.maxStreamCharacters))
- records[index].truncated = true
- }
- while records.count > 1 && records.reduce(0, { $0 + $1.stdout.count + $1.stderr.count + $1.lineCharacters }) > Self.maxTotalCharacters {
- omittedHistory = true
- records.removeFirst()
+ if let retention {
+ if records[index].stdout.count > retention.maxRecordOutputCharacters {
+ records[index].stdout = String(records[index].stdout.suffix(retention.maxRecordOutputCharacters))
+ records[index].truncated = true
+ }
+ if records[index].stderr.count > retention.maxRecordOutputCharacters {
+ records[index].stderr = String(records[index].stderr.suffix(retention.maxRecordOutputCharacters))
+ records[index].truncated = true
+ }
+ while records.count > 1 && records.reduce(0, { $0 + $1.stdout.count + $1.stderr.count + $1.lineCharacters }) > retention.maxTotalOutputCharacters {
+ omittedHistory = true
+ records.removeFirst()
+ }
}
case "finished":
- records[index].state = event.exitCode == nil ? .unconfirmed : .completed
+ records[index].state = event.state == "completed" ? .completed : .unconfirmed
records[index].exitCode = event.exitCode ?? -1
records[index].expectedExit = event.expectedExit ?? false
records[index].duration = event.durationMilliseconds
@@ -234,7 +259,7 @@ package final class GitExecutionContext: @unchecked Sendable {
private func appendRecord(_ record: Record) {
records.append(record)
- if records.count > Self.maxRecords { omittedHistory = true; records.removeFirst(records.count - Self.maxRecords) }
+ if let maxRecords = retention?.maxRecords, records.count > maxRecords { omittedHistory = true; records.removeFirst(records.count - maxRecords) }
dirty = true
}
diff --git a/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift b/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift
index 4f88435da8..336100a2a6 100644
--- a/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift
+++ b/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift
@@ -2,6 +2,10 @@ import Foundation
@testable import LitheGitModule
import Testing
+/// Core's journal budget as `requestStarted` carries it.
+private let coreRetention = GitJournalRetention(
+ maxRecords: 200, maxRecordOutputCharacters: 32_768, maxRecordOutputLines: 2_000, maxTotalOutputCharacters: 1_048_576)
+
struct GitExecutionTests {
@Test
func contextKeepsPreflightFailuresSeparateFromEarlierRequests() throws {
@@ -15,14 +19,15 @@ struct GitExecutionTests {
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: root)
context.receive(GitExecutionEvent(operationId: "workflow", type: "started", invocationId: 1,
workingDirectory: root.path, arguments: ["config", "pull.rebase", "true"]))
- context.receive(GitExecutionEvent(operationId: "workflow", type: "finished", invocationId: 1, exitCode: 0))
- context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished"))
+ context.receive(GitExecutionEvent(operationId: "workflow", type: "finished", invocationId: 1, exitCode: 0, state: "completed"))
+ context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished", unconfirmedInvocations: [], failureRecord: false))
let original = try #require(context.drainSnapshot()?.first)
let otherRoot = URL(fileURLWithPath: "/other-workspace")
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: otherRoot)
let failure = GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "process_start_failed", message: "Could not inspect Git executable", details: "Missing executable"))
+ error: .init(code: "process_start_failed", message: "Could not inspect Git executable", details: "Missing executable"),
+ unconfirmedInvocations: [], failureRecord: true)
context.receive(failure)
let entries = try #require(context.drainSnapshot())
#expect(entries.count == 2 && context.hasEntries)
@@ -41,14 +46,15 @@ struct GitExecutionTests {
let context = GitExecutionContext(operationID: "workflow", source: .user)
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: URL(fileURLWithPath: "/workspace"))
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "cancelled", message: "Operation was cancelled")))
+ error: .init(code: "cancelled", message: "Operation was cancelled"), unconfirmedInvocations: [], failureRecord: false))
#expect(context.drainSnapshot() == nil, "A superseded request is not a failed command")
#expect(!context.hasEntries)
// A different failure on the same context is still reported.
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: URL(fileURLWithPath: "/workspace"))
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "process_failed", message: "Git process cleanup did not complete within its deadline")))
+ error: .init(code: "process_failed", message: "Git process cleanup did not complete within its deadline"),
+ unconfirmedInvocations: [], failureRecord: true))
#expect(context.drainSnapshot()?.last?.operationErrorMessage == "Git process cleanup did not complete within its deadline")
}
@@ -56,10 +62,11 @@ struct GitExecutionTests {
func contextPreflightFailureHistoryRemainsBounded() {
let context = GitExecutionContext(operationID: "workflow")
for index in 0..<201 {
- context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"),
+ context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted", retention: coreRetention),
at: URL(fileURLWithPath: "/workspace"))
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "process_start_failed", message: "Failure \(index)")))
+ error: .init(code: "process_start_failed", message: "Failure \(index)"),
+ unconfirmedInvocations: [], failureRecord: true))
}
let entries = context.drainSnapshot()
#expect(entries?.count == 200 && context.hasOmittedHistory)
@@ -103,9 +110,10 @@ struct GitExecutionTests {
let journal = GitExecutionJournal()
for index in 0..<201 {
let id = "preflight-\(index)"
- journal.receive(GitExecutionEvent(operationId: id, type: "requestStarted"), at: URL(fileURLWithPath: "/workspace"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "requestStarted", retention: coreRetention), at: URL(fileURLWithPath: "/workspace"))
journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished",
- error: .init(code: "process_start_failed", message: "Could not inspect Git executable")))
+ error: .init(code: "process_start_failed", message: "Could not inspect Git executable"),
+ unconfirmedInvocations: [], failureRecord: true))
}
#expect(journal.snapshot.count == 200)
#expect(journal.hasOmittedHistory)
@@ -148,8 +156,8 @@ struct GitExecutionTests {
for id in ["publish", "stage", "branch", "commit"] {
journal.receive(GitExecutionEvent(operationId: id, type: "output", invocationId: 1,
stream: "stdout", text: id))
- journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0))
- journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0, state: "completed"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished", unconfirmedInvocations: [], failureRecord: false))
}
let entries = journal.snapshot
#expect(entries.map(\.arguments) == [["add"], ["commit"], ["checkout"], ["push"]])
@@ -174,7 +182,7 @@ struct GitExecutionTests {
journal.receive(GitExecutionEvent(operationId: "new", type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["checkout"]))
journal.receive(GitExecutionEvent(operationId: "new", type: "requestFinished",
- error: .init(code: "cancelled", message: "Operation was cancelled")))
+ error: .init(code: "cancelled", message: "Operation was cancelled"), unconfirmedInvocations: [1], failureRecord: false))
#expect(journal.snapshot.last?.state == .unconfirmed)
#expect(journal.snapshot.last?.operationErrorMessage == "Operation was cancelled")
}
@@ -283,6 +291,7 @@ struct GitExecutionTests {
@Test
func consoleBoundsEmptyRowsAndPreservesLatestStreamOrder() throws {
let context = GitExecutionContext(operationID: "operation")
+ context.receive(GitExecutionEvent(operationId: "operation", type: "requestStarted", retention: coreRetention))
context.receive(GitExecutionEvent(operationId: "operation", type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["fetch"]))
for _ in 0..<2_100 {
@@ -325,7 +334,7 @@ struct GitExecutionTests {
#expect(progress?.progressText == "Receiving: 50%")
#expect(progress?.standardError == "")
try receive(#"{"operationId":"operation","type":"output","invocationId":1,"stream":"stderr","text":"updated remote","progress":false}"#)
- try receive(#"{"operationId":"operation","type":"finished","invocationId":1,"exitCode":0,"durationMilliseconds":42}"#)
+ try receive(#"{"operationId":"operation","type":"finished","invocationId":1,"exitCode":0,"state":"completed","durationMilliseconds":42}"#)
let finished = context.drainSnapshot()?.last
#expect(finished?.succeeded == true)
#expect(finished?.durationMilliseconds == 42)
@@ -354,6 +363,7 @@ struct GitExecutionTests {
@Test
func unrelatedRequestsAndExcessOutputStayBounded() {
let context = GitExecutionContext(operationID: "operation")
+ context.receive(GitExecutionEvent(operationId: "operation", type: "requestStarted", retention: coreRetention))
context.receive(GitExecutionEvent(operationId: "other", type: "started", invocationId: 1, workingDirectory: "/wrong", arguments: ["fetch"]))
#expect(context.drainSnapshot() == nil)
context.receive(GitExecutionEvent(operationId: "operation", type: "started", invocationId: 1, workingDirectory: "/workspace", arguments: ["fetch"]))
@@ -370,12 +380,13 @@ struct GitConsolePresentationTests {
@Test
func omittedCommandsAreDistinguishedFromReversibleOutputFolds() {
let journal = GitExecutionJournal()
+ journal.receive(GitExecutionEvent(operationId: "budget", type: "requestStarted", retention: coreRetention))
for index in 0..<205 {
let id = "query-\(index)"
journal.receive(GitExecutionEvent(operationId: id, type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["status"]))
- journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0))
- journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0, state: "completed"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished", unconfirmedInvocations: [], failureRecord: false))
}
#expect(journal.snapshot.count == 200)
#expect(journal.hasOmittedHistory)
@@ -413,7 +424,7 @@ struct GitConsolePresentationTests {
context.receive(GitExecutionEvent(operationId: "query", type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["config", "--get", "remote.origin.skipFetchAll"]))
context.receive(GitExecutionEvent(operationId: "query", type: "finished", invocationId: 1,
- expectedExit: true, exitCode: 1))
+ expectedExit: true, exitCode: 1, state: "completed"))
let entry = try #require(context.drainSnapshot()?.last)
#expect(entry.succeeded)
#expect(entry.exitCode == 1)
@@ -439,7 +450,7 @@ struct GitConsolePresentationTests {
for (id, command) in [(1, "fetch"), (2, "for-each-ref")] {
context.receive(GitExecutionEvent(operationId: "transfer", type: "started", invocationId: id,
workingDirectory: "/workspace", arguments: [command]))
- context.receive(GitExecutionEvent(operationId: "transfer", type: "finished", invocationId: id, exitCode: 0))
+ context.receive(GitExecutionEvent(operationId: "transfer", type: "finished", invocationId: id, exitCode: 0, state: "completed"))
}
context.receive(GitExecutionEvent(operationId: "transfer", type: "remoteResult", remote: "origin",
succeeded: true, invocationId: 1))
diff --git a/shared/platform-feature-matrix/features/git-history.json b/shared/platform-feature-matrix/features/git-history.json
index 0e0ade1b66..d93c82646a 100644
--- a/shared/platform-feature-matrix/features/git-history.json
+++ b/shared/platform-feature-matrix/features/git-history.json
@@ -138,6 +138,6 @@
"verificationStatus": "pending"
},
"owner": "Git",
- "verification": "双端多仓库打开 Git Log:隐藏中间工作树后其余仓库颜色保持,切换工作区不得显示旧颜色;共享 Core fixture、跨页桥接和异步界面测试覆盖,完整原生界面及滚动性能验收 pending。 提交历史由 Core 保留作者时区及绝对时间,macOS 与 Windows 日期展示使用该时间;macOS 日期范围通过 Core 按绝对边界比较,系统日历计算夏令时边界。真实 Git、跨时区、重复小时、23/25 小时日及原生缓存回归覆盖;完整双平台界面人工验收仍 pending。 Windows cherry-pick 资格根据当前 HEAD 标识判断,不能把筛选结果第一行当作 HEAD;真实菜单回归覆盖其他分支首行可操作和非首行 HEAD 禁用。分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;macOS Git Log 工具窗口标签悬停有反馈,点击窗口内外时选中标签背景在活动色与非活动色之间切换,标签文字保持相同颜色与常规字重,按下时不变暗;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则;macOS Git Log 不再显示单独的 Fetch/Compare/Checkout/Cherry-pick 横向操作栏,分支树与竖向工具栏分离;检查 Fetch、分支比较、分支右键 Checkout、提交右键 Cherry-pick 和 Fetch 设置仍可用,分支搜索可筛选嵌套路径,并隐藏无匹配项的分组、显示无结果提示;定位按钮滚动到当前所选分支的首个可见提交而不重新加载历史;Git Log 的分支和提交搜索框复用 全局唯一的 LitheSearchFieldStyle,对照 IDEA SearchFieldWithExtension,核对 13pt 常规提示文字、Islands 明暗控件底色与边框色、36pt 外框内的 30pt 可见高度、3pt 外层留白、1pt 常态内描边、向外扩 1pt 的 2pt 聚焦描边及 4pt 圆角;图标与文字间距为 2pt,输入框清除按钮复用已有 16pt IDEA SVG 和 20pt 按钮区域,按上游预留清除图标宽度,移除分支搜索框重复外层水平内边距,提交搜索框按 15 列字体度量和 150pt 最小宽度布局,Search 与 Database 搜索输入框使用相同共享样式;竖栏更新按钮采用 IDEA ExpUI 的明暗 update 图标,按钮与侧边栏图标悬停使用共用工具提示样式,提示的明暗背景、文字色、1pt 内描边、圆角及内边距对齐 IDEA HelpTooltip;隐藏与显示分支使用 IDEA ExpUI 的 chevronLeft/chevronRight 明暗图标;竖栏宽 36pt,展开态按钮为 22pt 且四周保留工具栏内边距,展开态分支树左边框复用与 Commit 标题下边框同源的 1pt 分隔线;收缩态竖栏常态复用展开态侧栏背景,展开与收缩保留提交图谱和详情视图实例、滚动状态及分支宽度,仅切换分支面板与分隔条的占用宽度,按钮占满竖栏,深色 hover 使用 Islands Dark 的 #FFFFFF17,浅色 hover 使用 ExpUI Gray12,不在右侧显示 Show Branches 浮层提示,图标在顶部留 8pt 并使用常规小字号文字;展开后恢复分支树宽度。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 Git Log 分支树按 IDEA Community Islands 普通密度核对 24pt 行高、无行间额外间距、13pt 常规文字、12pt 次要计数与 4/12pt 树边距;展开箭头、路径文件夹、普通分支、当前分支和标签使用上游 16pt 明暗 SVG,Local/Remote/Tags 标题不附加图标,当前分支不显示额外蓝色勾选;HEAD 与当前分支是两个互斥选中节点,点击分支、HEAD、清除筛选和切换仓库时核对选中状态;深浅主题核对树行焦点/失焦选中色及 hover,保留多仓库名称、圆点、数量和只读菜单,移除分支行仓库色条;Windows 多仓库分支树的仓库行与 macOS 一致,仓库行使用中性色文件夹图标,并只在图标与名称之间显示 8px 仓库色点,仓库名保持普通文字色,不出现整组着色卡片底色、左侧色条或彩色名称,截断名称通过共享工具提示显示全名,行悬停为箭头光标;确认搜索、分组折叠、右键操作、缓存行比较和滚动状态正常。 Git Log 分支树右侧分隔线与 Commit 标题下边框共用 LitheTheme.toolWindowBorder 和 1pt 厚度,深色为 #26282C、浅色为 #E9EAEE;悬停、拖动开始/移动/结束均保持同色,不高亮,保留原生调整光标、命中区域、拖动尺寸约束及提交宽度行为。 Git Log 中间栏按 IDEA Community FilterComponent、VcsLogGraphTable 和 PaintParameters 核对四个筛选器的 13pt 常规文字、4pt 内边距、16pt 箭头和仅文字悬停反馈;工具栏使用 16pt IDEA 明暗 SVG、22pt 点击区域和共享提示,保留动作及禁用条件;提交列表为 26pt 连续行,图谱尺寸同比缩放,作者与日期同为 13pt 主文字色,日期列按打包字体度量,核对深浅主题行 hover、焦点/失焦选中色及选中覆盖 hover,筛选、分页、箭头命中、上下键导航和右键操作不回归。 分支竖栏设置齿轮复用 LitheIconButtonStyle 的 22pt 点击区域、4pt 圆角和主题 hover/按下背景;点击使用 LitheContextMenuPresenter 的共享操作菜单背景、1pt 边框、行 hover 与键盘导航,保留 Fetch Options 对话框、Show Worktree Repositories 勾选和无工作树时禁用行为,点击外部或按 Esc 关闭菜单。 macOS 下拉菜单仅保留 Project 同源的一套全局共享样式,移除 settingsStyle 切换;Project、Git Log 设置、Terminal、欢迎页及右键子菜单统一使用 8pt 外框圆角、1pt 内描边、24pt 行高、12.5pt 文字、6pt 外留白和 8pt 行内边距,设置选择下拉框共享同一外框与尺寸定义; Git Log 顶部按 IDEA 恢复 Branch: 已选分支名称,保留 Branch 筛选、清除和菜单内勾选;Branch、User、Date、Paths 按 IDEA FilterComponent 保留 4pt 内边距、3pt 箭头间距和 13pt 常规文字,四项文字 hover 为白色且不增加底色。Date 使用共享菜单呈现器,Branch/User 的可搜索列表与分组子菜单使用共享 LitheDropdownRowStyle、24pt 行高和共享菜单外框,Paths 输入面板复用共享菜单外框及搜索输入框样式,保留搜索、选项选中、路径 Apply/Cancel/Clear 和时间范围计算。 Branch/User/Paths 原生弹窗锚点不参与鼠标命中,核对按钮与文字收到 hover/点击、菜单正确弹出且关闭后可再次打开;Date 保持共享菜单呈现器。共享下拉外框裁剪内部内容,明暗主题下 Branch/User 搜索条不能覆盖圆角;Paths 输入框复用共享输入样式并显示正确焦点边框,Clear/Cancel/Apply 复用共享次按钮/主按钮,保留路径应用和清除行为。Branch、User、Paths 的可搜索内容同样使用 LitheContextMenuPresenter 的透明无边框面板,去掉 NSPopover 系统外框,复用 Project 共享背景、1pt 边框和圆角;分组展开与搜索动态调整弹窗尺寸,Esc、点击外部和切换菜单正确关闭。选择分支后清除筛选,确认 Branch 恢复默认标题且关闭按钮消失;关闭按钮保留 16pt closeSmall 明暗 SVG、22pt hover 区域及 4pt 圆角。展开分支树左侧固定线、中间分隔线和右侧详情拖动线均使用 toolWindowBorder 的 1pt 边框,右侧 hover 与拖动不高亮。右侧详情展示图标使用 IDEA previewVertically 明暗 SVG,装饰开关使用 show 眼睛而非 hide 工具窗口减号;所有工具栏展示图标复用 22pt、4pt 圆角的共享 hover 背景,保留原动作与提示。深浅主题渲染核对背景和边框,保留可选图标、勾选、快捷键、禁用、子菜单和键盘导航。 Git Log 提交标题、作者和日期使用 Inter 13 Normal,引用标签 12pt;核对至少 26pt 的字体度量行高、含 leading 的整数基线、长标题与中文/emoji 截断,以及选中合并提交的文字颜色。 所选分支存在时 Log 标签显示 Log: 分支短名(包含 codex/ 路径),选择另一分支立即更新;清除分支筛选后恢复 Log,保留本地化标题、原始分支名和原标签选择/焦点行为。 macOS 标题起点按缩放图谱列距、图文间距和 2pt 文字内边距计算,为对齐参考截图至少预留六列,密集行继续扩宽,不改变图谱节点、连线和箭头命中;在 SwiftUI/AppKit 两条渲染路径核对未选中的合并提交标题、作者、日期统一使用合并灰色,选中后恢复普通文字色,合并判定仅依赖至少两个父节点。 Git Log 右侧文件树使用共享 24pt 行高、19pt 层级缩进、常规字重、文件类型图标和 IDEA FileStatus 文件名颜色,无逐行横线及工具栏重复总数;核对文件夹折叠、文件打开 diff、长名称 hover 展开与横向滚动,拖动宽度不重建行内容。提交详情使用 14pt 外边距、10pt 内间距、等宽 bold 标题、同段作者邮箱时间与引用标签,长内容可滚动;文件树/详情之间分隔线复用共享边框,hover/拖动不高亮。左侧分支树从 180pt 专用下限改为共享 30pt 下限,独立 36pt 竖栏保持原宽,窄树裁剪后可拖回原宽,收缩/展开不重建图谱。 图谱圆点、连线、箭头按 SimpleGraphCellPainter/ PaintUtil 的 FLOOR/ODD 物理像素规则对齐;在 1×/2× 原生位图验证圆点与线宽,在真实 NSHostingView 检查对齐后的上下斜向箭头尖端及内侧描边可导航,切换屏幕只重绘且绘制/命中坐标一致。 时间列复用 Inter 13 常规字体的等宽数字与固定 AM/PM 区域,按应用语言使用英文 12h AM/PM 或中文 24h 格式;核对午夜、正午、未知日期回退、行起点一致及切换语言后的原生日期缓存失效,列表两条渲染路径和提交详情使用相同格式。 连续上下拖动 Git Log,包含上方长 Diff:原生列表只调整可见区域,保持文档身份、滚动位置、Shift/Command 多选、焦点色、键盘与箭头导航、分页和辅助功能;收缩再恢复后列表宽高正确。GitGraphInteractionTests 使用 300 条提交、80 次原生尺寸变化和共享分隔条事件验证,局部新旧路径计时作为诊断,不代替目标工作区帧率验证。 共享分隔容器按已有宽高直接分配两个面板与分隔条的确定矩形,避免 HStack/VStack 最小及理想尺寸探测;splitPaneBounds 覆盖横纵方向、leading/trailing、折叠、子视图身份和有限尺寸,达到尺寸限制后的相同结果不重复写状态。 通过键盘或程序化切换焦点时 Git 标签活动态双向更新;挂载或重新显示 Git Log 不主动抢走编辑器焦点。 在包含展开分支树的 Git Log 中连续拖动外层高度和内部宽度:共享分隔器只在拖动开始/结束传播环境状态,嵌套容器继承祖先状态;拖动期间共享行悬停不更新、提示不登记几何锚点、右键覆盖层暂停,松开后恢复菜单和提示,行内容及滚动状态不重挂载。69 行原生分隔条事件回归检查覆盖层暂停/恢复及内容身份;未悬停控件在平时同样不登记提示锚点。用目标工作区 Time Profiler/Animation Hitches 对照 Terminal 核对持续更新成本及起止开销,split.drag 整次手势时长不能当作单帧延迟;本次仅前端共享交互装饰调整,不修改后端及默认详情高度行为。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 四个筛选弹窗均以各自按钮左下角为定位锚点,Date 不依赖鼠标点击坐标;空间不足时限制在屏幕可见范围内。原生面板回归核对操作菜单/可搜索内容相同锚点下的左上角、无展开动画和 Esc 关闭回调。共享搜索字段在未聚焦时悬停也显示文本编辑光标,离开或禁用时恢复箭头。 Git Console 左侧六个现有动作使用 Community 原始 16pt 明暗 SVG;22pt 背景、竖向 2/1 按钮留白、5/7 外边距和默认箭头光标。核对查找、软换行、跟随、停止、清空、复制的原有动作与禁用状态,组件资源检查不替代完整应用验收。 按 Community CurrentBranchHighlighter 标出当前本地分支可达的所有父提交,包含合并父节点;深色 #1D2336、浅色 #EDF3FF,选中背景优先,仅已加载图谱可证明的祖先被标记,当前分支/HEAD 单独筛选时不重复染色。引用默认显示并按 GitRefManager/SimpleRefGroup 紧凑合组:本地与上游同提交显示 remote & branch,HEAD/本地/远端/标签使用官方黄/绿/紫/灰轮廓及叠层,标签单独存在时默认只显示图标;长名称按列宽和 22 字符下限缩写,原生可见区 tooltip 保留所有完整名称,滚动后按当前鼠标位置解析。明暗原生渲染检查颜色、宽度、选择优先级及完整名称;GitLogReferenceResizeTests 在真实 GitLogView 中加载 1000 个分支并拖动外层高度,检查可见右键覆盖层数量和挂载生命周期,计时仅作诊断。最新正式预览已进入该工作树,观察全部引用下的深色背景、叠层缩写标签和多次上边缘拖动后的布局恢复;用户随后在本机明确确认“现在不卡顿了”,本次报告的高度拖动卡顿通过用户实机验收;组件计时和混合工作区采样不用于推断唯一根因或量化通用帧延迟,其他主题/悬浮等视觉项继续按各自范围验收。 在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Commit files 树中过长的文件或目录名不折叠为省略号,区域随最宽行横向展开,可通过横向滚动条或触控板侧滑左右滑动查看完整名称(与源代码管理变更树一致);Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列将有 UTC 偏移的提交转换到本机时区后显示相对时间(刚刚、N 分钟前、今天/昨天);英文绝对日期和时间通过 Windows 原生接口遵循系统短日期/时间及用户自定义格式,中文使用中文地区格式,未知偏移仍显示绝对日期;验证美式月/日/年、英式日/月/年、自定义分隔符、12/24 小时制、跨时区相对时间和迟到原生结果保护,真实 WebView2 产品验收为 pending;鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 和提交标识,可通过文件导航分别查看;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 Windows 端提交历史仅通过 Git Log 工具窗浏览:源代码管理侧栏入口已更名为 Commit 面板,其 History 标签已移除,面板固定显示变更列表与提交区。命令面板中的 Git: Show History 入口同步移除。 Windows Commit Files 单文件预览使用独立页面:上下箭头导航差异,下箭头在当前文件最后一处后继续进入下一文件并在比较完成后定位第一处差异,整个文件集合末尾禁用且不循环;图片、二进制和无文本差异文件也允许下箭头继续,比较未就绪时禁用以防连续点击跳过文件。左右箭头直接切换当前比较集合的上一文件/下一文件;标签使用 IDEA 的 Repository Diff: 文件名并跟随导航更新,不附加提交 hash。Jump to Source 按最后聚焦侧的补丁位置打开预览所属仓库的文件,重命名使用新路径、删除行映射邻近保留行、已删除文件禁用源跳转;模型更新期间禁用差异动作。保留并排/统一视图、空白显示、Monaco 查找、图片和二进制呈现,普通全量提交比较仍使用多文件页面。真实 WebView2 内核及源文件打开还需目标产品运行验收。 Windows 单文件历史 Diff 外观验证:并排视图使用独立外侧 12px 滚动轨道、8px 滑块和概览,通常的统一视图保留 Monaco 原生 Diff 组件,整文件新增使用真实新行流的独立只读控件,深浅主题检查常态/悬停/拖动色、圆角及边框;实际拖动任一并排滑块后按差异边界同步对应行且模型身份保持。工具栏使用现有 Button,核对 40px 高度和 22px 图标按钮;保留用户字体与字号,默认行距按 14px 字号对应 22px 并随缩放,自定义行距不覆盖。词级高亮开关复用现有 Monaco 装饰能力,关闭只隐藏字符差异、保留整行差异,切换文件保留词级和空白开关。旧/新版本标题显示真实第一父提交、范围基线/目标或非连续提交各自身份,根提交旧侧为空树,重命名显示旧/新路径;短 hash 和截断路径的 tooltip 保留全文。并排标题列宽由独立布局所有者更新 CSS,拖动分隔条时标题跟随且不重建模型;统一视图标题纵向排列。以上浏览器真实组件验证不替代目标 WebView2 产品验收。 Windows 单文件 Diff 滚动条回归:两侧横纵滚动条默认显示,鼠标移到工具栏后仍可见,不依赖悬停;差异概览不绘制重复的宽矩形视口指示,并排由独立外侧轨道负责点击、拖动和滚轮,统一视图保留原生委托。明暗主题、并排/统一视图及长文本双向溢出下检查唯一可见滑块、并排横向同像素偏移同步、纵向对应行同步和模型身份。 历史 Diff 两侧滚动轨道与代码编辑器概览画布背景透明,鼠标不悬停时滑块仍显示;关闭代码编辑器重复概览通道,但保留 Diff 专用差异色条。横向轨道不额外增加内容高度,检查代码能显示在滑块后、底部内容与滚动位置正确,拖动命中保留,并排两侧横向同像素偏移同步、纵向对应行同步。 Windows 并排单文件 Diff 使用两个独立只读 Monaco 代码控件,取消 DiffEditor 的补空白对齐。左右横向滚动按同一像素偏移双向同步,较短侧由 Monaco 钳制到真实内容边界且不反向拉回主动侧;纵向原生代码区、外侧滑块拖动/点击、轨道键盘、滚轮和查找以视口三分之一处为锚点,按 Git 差异块起止边界同步对应行并保留行内偏移,短侧在不等长块内钳制、共同上下文恢复对齐。同步有重入与微小方向保护;双模型替换、布局、字体配置和显式上下导航期间暂停同步。新增旧侧/删除新侧画横跨可见代码区的双像素零行边界,修改区画上下单像素边界;两列行号背景为直边,曲线只在两列之间弯曲,实际偏移改变端点形状。检查文件开头/末尾零行、三态、明暗主题和窄列,代码不补空白、不扩展横向范围;连接面覆盖两列历史行号背景,行号选择和源映射正确。左右外侧各显示透明常显纵向滑块与增删改概览,横向保留原生滚动条,滚动范围截止真实最长代码行。验证深浅主题、折叠展开、Ctrl/Cmd+F 搜索折叠文本、词级开关和预算、统一视图及跨文件首处定位、连续拖动和窄窗口非负尺寸。高频帧二分筛选可见差异,缓存像素边界及概览,不扫描全文、不更新 React;卸载和迟到语言准备清理所有帧、监听、观察器、拖动、两个编辑器和模型。浏览器生产组件验证使用平台记录器,真实 WebView2 产品验收仍为 pending。 外侧轨道预留独立空间,不显示代码;左轨道距面板边缘、右轨道距代码视口各留 4px 间隙,窄列随可用宽度缩减,右侧文字与轨道不重叠;并排和统一预览关闭缩进及括号引导线,保留语法颜色;中央行号缩小边距和曲线间隙,整数布局边界及上下半像素描边消除与代码区的垂直接缝。滚轮在代码、行号和轨道统一加快到两倍,拖动仍映射各侧真实进度,纵向同步对应行;方向键按用户行高移动。有词级结果时修改正文底色按 40% 修改色与 60% 页面背景混合,完整修改色突出替换词的两侧,纯新增和纯删除词分别使用完整增删色,语法前景色和文字透明度保持;关闭词级或超限保留整行底色。验证三态与开头末尾、双向对应行同步、明暗主题、轨道无遮挡、宽窄字体布局和预算回退。 单文件并排及统一视图隐藏补丁 @@ 标题,但保留真正以 @@ 开头的源码、行 ID、源行映射和原始补丁;默认多文件与暂存投影不变。Islands Dark 继承 Darcula 背景,Islands Light 使用 Light 方案,独立差异轨道标记与连接、代码边界共用对应差异背景色。底部保留五个用户实际行高的 padding,模型不增加假行;从任意侧滚到底部验证共同末行对齐与五行留白,字号/行距变化同步更新。 Windows 单文件 Diff 首次打开、左右切文件和同路径新比较均在模型/比较准备完成后定位第一处改动;定位只消费一次,调整词级、空白、字号或主题保持用户当前位置。统一视图的原生 Monaco 增删行、字符与行号栏使用该预览相同的 Islands 背景变量,移除默认字符边框;新增整文件应为深色 #294436 或浅色 #BEE6BE,删除为 #484A4A 或 #D6D6D6,语法前景色不变且其他编辑器主题不被覆盖。 整文件新增采用单流只读代码控件,隐藏不存在的旧侧与连接带,新增背景覆盖行号、代码和底部留白,避免空旧模型的灰色删除行及外圈;新增仅显示新版本、删除仅显示旧版本的一组 hash 与路径。并排两侧及新增单流轨道的增删改标记与对应内容背景共用颜色;原生统一预览关闭 renderIndicators,历史行号旁不显示 +/-。这组新增显示修复按用户要求延后运行验证,提交或 PR 前核对新增文件、普通修改、删除、深浅主题、查找、源跳转与底部留白。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 右下角 Commit files 列表同样跟随 Diff 的上一/下一文件导航:选中行移到当前文件,被折叠的父目录自动展开,行滚动到可见位置;预览属于其他提交或范围时不改变选中,点击行不会改变横向滚动。 Windows Git Log 筛选后基于完整已加载历史投影可见祖先:隐藏中间提交以虚线连接,直接父子保持实线,无关分支不误连;合并路径去重并保留永久颜色,未加载父提交经隐藏节点传播,分页补齐后解除缺失标记;清空筛选恢复原图,核对文本、作者、分支名称三种范围、空结果与选择行为。算法和真实表格组件回归通过不代替 WebView2 原生验收,原生验证保持 pending。 Windows 未筛选图与筛选图共用上下半边打印元素:普通浏览的跨行泳道换列必须在共同中点连续连接,合流入边保留各自颜色到提交圆点;核对原始 IDEA 200 条历史输出的列号、方向、终止和颜色身份;截图/原生交互验收仍为 pending,组件输出和 golden 对照不代替 WebView2 验收。 Windows Git Graph 外观:核对明暗主题真实 IDEA RGB、实心节点、直线与筛选虚线、终止箭头及至少六列标题预留;默认 26px 行高,用户字号变化时虚拟行与 SVG 同步扩高;在 100%/125%/150%/200%/300% 缩放及跨屏 DPI 变化下核对奇数物理像素和相邻半边连续性,普通视口调整不重建历史图。箭头能跳到已加载且可见的父/子提交,未加载父节点没有可点击目标;浏览器生产组件验证不代替目标 WebView2 运行验收,原生验证保持 pending。 Windows 历史排序和图导航:在交错分支仓库核对首批/分页/回退及全引用上下文均使用日期顺序,切换分支保持可证明的永久颜色和基础顺序;上下文最多 5000 条,覆盖不完整或哈希重复时保留可见页并回退。核对 upstream/backup 等非 origin 远程引用分类及本地含斜杠分支;背景上下文不阻塞 50 条可见页,分页不取消或重读上下文,切仓库/刷新/卸载取消旧操作且关闭迟到游标。长边开关展开/收起不改变提交、筛选、选择和游标;验证 30/1000 行边界。点击或用 Enter/空格激活上下箭头,选择并滚动到屏幕外端点,挂载后键盘焦点随目标;不误选源行、不误开源行 Diff,保持普通选择/多选/双击/右键和原预览入口。明暗主题及 1x/1.25x/2x 检查箭头尖端与命中范围,真实 WebView2 运行验收保持 pending。 图谱颜色必须跟随 ThemeRegistry 根 data-theme-type 切换;不依赖页面存在 dark class。复用真实 Lithe 明暗主题检查同一已挂载节点与连线的颜色变化、布局不因主题重算,结合主题回归和浏览器真实切换验证。 Windows 分页稳定性:选中顶部提交后向下滚动触发连续分页,确认滚动位置不跳回已选提交,Commit Files 不清空或重读,已选文件及树折叠保持;更新引用、背景上下文和相同 hash 的元数据同样不导航。定位分支头通过独立请求仍能定位已经选中的头提交一次;新选择或切仓库仍读取新文件并丢弃旧结果。组件回归与真实浏览器虚拟列表/IntersectionObserver 验证通过,目标 WebView2 实机验收保持 pending。 Windows Git Log 标题栏不显示固定 Read-only/只读文字,关闭按钮保持靠右,刷新、设置及隐藏操作正常。 Windows Git Log 单个提交右键 New Tag… 输入非空且无空白的标签名,创建指向右键提交完整 hash 的轻量标签并刷新引用和历史;取消不写入,失败保留名称可重试,双击/Enter 不重复创建,多选和写操作期间禁用。仓库或工作区切换关闭旧弹窗,旧请求迟到不得刷新或关闭新弹窗。 Windows 提交区域点击后上下箭头更新可见提交选择和详情,焦点不随虚拟行移除而丢失;筛选、首尾边界、Home/End、Shift 范围选择和 Enter 正常,菜单与输入框保留自己的方向键。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 Windows Git Log 提交右键 New Tag 使用与 Tags 节点同源的 TagIcon(沿用现有标签轮廓图案),核对 16px 图标、菜单对齐和禁用反馈;新补图标的实机视觉确认待完成。 Windows 提交列表菜单键和 Shift+F10 打开当前选中提交的真实菜单;关闭后列表继续上下键导航,菜单操作仍针对所选提交。New Tag 在 tag.gpgSign=true 下生成轻量标签,不运行签名器或消息编辑器,不修改配置。 Windows 提交行高亮和紧凑引用组:在全部引用和其他分支视图核对当前检出分支全父祖先背景,当前分支/HEAD 视图关闭重复高亮;合并提交的说明、作者、日期未选中时弱化、选中后恢复,选中背景优先于分支并区分列表/文件树/窗口焦点。核对明暗主题固定基准色、真实 upstream 合并名称、游离 HEAD、每种最多两个叠加图标、仅标签图标和完整汇总悬停/无障碍提示。宽窄列及字号变化时按至少 22 字符缩略规则布局,只有一个视口尺寸观察器,列拖动不逐行读取宽度,卸载清理;分页不丢滚动/选择,成功后不重读 Commit Files;文件读取失败时 Commit Files 面板显示重试入口,重新选择同一提交或刷新历史也会重试一次,长边导航和新建标签原入口保持。组件与浏览器验证通过,目标 WebView2 实机验收 pending。 Windows Git Log 分支树和提交列表的普通滚轮使用 Community Windows 默认 200ms 和 (0.5,0.505,0.5,1) 曲线;连续同向累积、反向从当前位置重启。核对两处列表的中间帧、Shift 横向滚动、分页保持、顶底边界、键盘定位和滑块拖动取消惯性、关闭工具窗清理帧以及减少动态效果偏好。确定性动画与 DOM 回归通过,真实 WebView2 设备手感验收保持 pending。 Windows graph 标题不再固定预留六列:建议宽度取可见图边数的加权均值与标准差,最多以六列作统一基线,每行按节点与斜边中点扩展,密集行不把其他简单行扩成最大列宽。验证单线/少分支、罕见密集合并、超过六列、筛选、长边开关、引用标签可用宽度及字号/DPI,保持图谱颜色/连线/导航不变。 Windows Git Log 的 Commit Files 文件树沿用 IDEA Windows 默认 200ms 滚轮动画;在文件行、竖向/横向滚动条区域连续滚动、反向及 Shift 横向滚动,确认共用真实视口的动画目标,长文件名保持自然宽度;按下滚动条取消未完成惯性,减少动态效果时直接滚动,切换提交及卸载不残留帧。其它 ScrollArea 默认行为不变。真实 WebView2 交互验收 pending。 Windows Git Log 右键 New Tag 打开弹窗后焦点直接位于标签名输入框,无需点击即可输入,Enter 提交、Esc 关闭。 macOS Git Log 空状态:在非 Git 项目检查明暗主题、窄窗口和键盘操作:13pt 常规文字按 IDEA 左对齐,整组位于内容区上方三分之一处;显示 To track changes to code、GitHub/GitLab 官网链接、Initialize Git repository…、Open Git Settings 和带原始 ContextHelp 图标的 Version control integration(打开 Git 官方文档)。初始化仍先确认,不自动暂存或提交;设置打开既有 Git 设置。未提交仓库、无匹配提交、加载失败及重试保持原动作,所有空状态操作采用文字按钮。 非 Git 项目空状态隐藏 Log/Worktrees/Console 标签、添加按钮、分支树与动作条、筛选/搜索工具栏及提交详情,提示占满工具窗内容区;GitHub、GitLab、初始化、设置及帮助链接悬停仅给当前文字加主题强调色下划线并显示手形,离开后恢复;初始化或切换至真实仓库后恢复原有三栏布局,真实仓库无匹配提交仍保留筛选入口。 空状态字体与间距对照 Community c7f91397:按钮标签显式使用 Inter 13pt Regular;行首破折号按首行基线对齐,21pt 行高加 2pt 间隔,帮助图标间隔 2pt;灰字与链接分别沿 Component.infoForeground 和 Islands Link.activeForeground 核对明暗主题,悬停下划线仍使用当前主题强调色。 macOS 右侧提交详情的分支标签按可见区域懒加载,全部引用仍可滚动查看。重开面板初始化时恢复匹配的提交图和分支树,第一次布局即可读取全部已缓存数据,不依赖出现后的异步任务回填。重开面板复用未变化的分支树,单仓库不重复构建分组副本,多仓库活动根复用已有三类行;引用、仓库、工作树开关、折叠状态改变后拒绝旧树。原生绘制与无障碍日期文本复用缓存,语言改变后刷新。 macOS 大量引用首屏:同提交放置 1,500 个本地分支和 1,500 个远程引用,核对真实 upstream 优先、同名远程自然排序回退、完整提示及每类图标上限;反复切换 Terminal/Git Log,数据版本相同应复用已准备图,改变分支/筛选/图显示选项后不得复用旧图。运行 GitGraphInteractionTests 的 manyReferenceGroups 和 presentationCache;组件耗时仅诊断,整机卡顿以相同数据实测为准。 macOS Git Log 及终端按需创建后由工作台持有实际原生宿主;隐藏再重开及移动停靠位置复用控件。运行 GitLogReferenceResizeTests,验证同一工作台隐藏再重开时,固定原生表面上的提交文档和滚动容器对象身份不变,恢复可见性且尺寸正确,工作台释放后宿主释放;核对语言、主题、搜索、分支操作和终端会话。 Git Log 与终端同时停靠时,从外层横向和纵向分隔条中央及向面板两侧偏移 4pt 处,经窗口发送按下、拖动、松开,确认完整 10pt 命中区仍能提交尺寸、宿主身份保持,面板中央正常交互。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 Windows HEAD 按 Community HeadNodePainter 核对外圈、背景间隔和中心实点:26px 行高、100% 缩放时三层直径为 13/9/5px,125%/150%/200%/300% 按物理像素 FLOOR/ODD 对齐;深浅主题、悬停、当前分支及活动/非活动选择均透出真实行底色,穿过 HEAD 的连线不侵入间隔。普通节点及分叉、筛选、长边展开保持;同边双向终止元素键唯一。目标 WebView2 实机验收保持 pending。 macOS 在明暗主题的窄 Git Log 工具栏中选择长用户名、分支和路径,确认名称与选中值以同一行尾部截断,文字不重叠,清除按钮和重新选择仍可用。 macOS Git Log 接近底部时自动追加较早提交,正常浏览不出现必须点击的加载更多按钮;单一页面最多发起一次边缘请求,追加保留选择和原生视口偏移。无新提交或缺失后续游标时停止并释放游标;失败暂停自动读取,显式刷新重开历史而不重用失败游标。运行 GitGraphInteractionTests.automaticHistoryPaging 与 GitModuleTests.continuousHistoryPagingStopsOnDuplicatePagesAndRefreshesAfterFailure,真实大仓库连续滚动和 Windows WebView2 验收仍 pending。 macOS 深层历史后台刷新:文件状态变化不重开首批,元数据刷新保留已加载窗口和后续页占位,未变结果不重建提交图;外部新提交按可见 hash 与行内偏移保持原生视口,按新行位移补齐旧尾部,保留尾部选择。当前仓库引用只读取一次,多仓库仍独立加载。后台刷新打断自动分页后,即使历史相同也能继续读取新游标;失败仍暂停自动重试。运行 GitModuleTests.backgroundRefreshRetainsDeepHistoryAndSelectionWhileExternalCommitsStillAppear、引用聚合回归及 GitGraphInteractionTests.backgroundHistoryRefreshAnchorsVisibleCommitInsteadOfSelectedCommit/automaticHistoryPaging;真实大仓库长时间滚动仍待用户验收,原生进程 cleanup 超时未独立复现,不将减少重复查询等同于修复所有进程失败。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。 历史菜单资格使用 Core 读取真实 HEAD 与第一父链;在筛选/分页、旁支首行、跳选、切换仓库、HEAD 变化和关闭菜单时验证动作状态及迟到结果隔离;两端原生界面验收 pending。",
+ "verification": "双端多仓库打开 Git Log:隐藏中间工作树后其余仓库颜色保持,切换工作区不得显示旧颜色;共享 Core fixture、跨页桥接和异步界面测试覆盖,完整原生界面及滚动性能验收 pending。 提交历史由 Core 保留作者时区及绝对时间,macOS 与 Windows 日期展示使用该时间;macOS 日期范围通过 Core 按绝对边界比较,系统日历计算夏令时边界。真实 Git、跨时区、重复小时、23/25 小时日及原生缓存回归覆盖;完整双平台界面人工验收仍 pending。 Windows cherry-pick 资格根据当前 HEAD 标识判断,不能把筛选结果第一行当作 HEAD;真实菜单回归覆盖其他分支首行可操作和非首行 HEAD 禁用。分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;macOS Git Log 工具窗口标签悬停有反馈,点击窗口内外时选中标签背景在活动色与非活动色之间切换,标签文字保持相同颜色与常规字重,按下时不变暗;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则;macOS Git Log 不再显示单独的 Fetch/Compare/Checkout/Cherry-pick 横向操作栏,分支树与竖向工具栏分离;检查 Fetch、分支比较、分支右键 Checkout、提交右键 Cherry-pick 和 Fetch 设置仍可用,分支搜索可筛选嵌套路径,并隐藏无匹配项的分组、显示无结果提示;定位按钮滚动到当前所选分支的首个可见提交而不重新加载历史;Git Log 的分支和提交搜索框复用 全局唯一的 LitheSearchFieldStyle,对照 IDEA SearchFieldWithExtension,核对 13pt 常规提示文字、Islands 明暗控件底色与边框色、36pt 外框内的 30pt 可见高度、3pt 外层留白、1pt 常态内描边、向外扩 1pt 的 2pt 聚焦描边及 4pt 圆角;图标与文字间距为 2pt,输入框清除按钮复用已有 16pt IDEA SVG 和 20pt 按钮区域,按上游预留清除图标宽度,移除分支搜索框重复外层水平内边距,提交搜索框按 15 列字体度量和 150pt 最小宽度布局,Search 与 Database 搜索输入框使用相同共享样式;竖栏更新按钮采用 IDEA ExpUI 的明暗 update 图标,按钮与侧边栏图标悬停使用共用工具提示样式,提示的明暗背景、文字色、1pt 内描边、圆角及内边距对齐 IDEA HelpTooltip;隐藏与显示分支使用 IDEA ExpUI 的 chevronLeft/chevronRight 明暗图标;竖栏宽 36pt,展开态按钮为 22pt 且四周保留工具栏内边距,展开态分支树左边框复用与 Commit 标题下边框同源的 1pt 分隔线;收缩态竖栏常态复用展开态侧栏背景,展开与收缩保留提交图谱和详情视图实例、滚动状态及分支宽度,仅切换分支面板与分隔条的占用宽度,按钮占满竖栏,深色 hover 使用 Islands Dark 的 #FFFFFF17,浅色 hover 使用 ExpUI Gray12,不在右侧显示 Show Branches 浮层提示,图标在顶部留 8pt 并使用常规小字号文字;展开后恢复分支树宽度。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 Git Log 分支树按 IDEA Community Islands 普通密度核对 24pt 行高、无行间额外间距、13pt 常规文字、12pt 次要计数与 4/12pt 树边距;展开箭头、路径文件夹、普通分支、当前分支和标签使用上游 16pt 明暗 SVG,Local/Remote/Tags 标题不附加图标,当前分支不显示额外蓝色勾选;HEAD 与当前分支是两个互斥选中节点,点击分支、HEAD、清除筛选和切换仓库时核对选中状态;深浅主题核对树行焦点/失焦选中色及 hover,保留多仓库名称、圆点、数量和只读菜单,移除分支行仓库色条;Windows 多仓库分支树的仓库行与 macOS 一致,仓库行使用中性色文件夹图标,并只在图标与名称之间显示 8px 仓库色点,仓库名保持普通文字色,不出现整组着色卡片底色、左侧色条或彩色名称,截断名称通过共享工具提示显示全名,行悬停为箭头光标;确认搜索、分组折叠、右键操作、缓存行比较和滚动状态正常。 Git Log 分支树右侧分隔线与 Commit 标题下边框共用 LitheTheme.toolWindowBorder 和 1pt 厚度,深色为 #26282C、浅色为 #E9EAEE;悬停、拖动开始/移动/结束均保持同色,不高亮,保留原生调整光标、命中区域、拖动尺寸约束及提交宽度行为。 Git Log 中间栏按 IDEA Community FilterComponent、VcsLogGraphTable 和 PaintParameters 核对四个筛选器的 13pt 常规文字、4pt 内边距、16pt 箭头和仅文字悬停反馈;工具栏使用 16pt IDEA 明暗 SVG、22pt 点击区域和共享提示,保留动作及禁用条件;提交列表为 26pt 连续行,图谱尺寸同比缩放,作者与日期同为 13pt 主文字色,日期列按打包字体度量,核对深浅主题行 hover、焦点/失焦选中色及选中覆盖 hover,筛选、分页、箭头命中、上下键导航和右键操作不回归。 分支竖栏设置齿轮复用 LitheIconButtonStyle 的 22pt 点击区域、4pt 圆角和主题 hover/按下背景;点击使用 LitheContextMenuPresenter 的共享操作菜单背景、1pt 边框、行 hover 与键盘导航,保留 Fetch Options 对话框、Show Worktree Repositories 勾选和无工作树时禁用行为,点击外部或按 Esc 关闭菜单。 macOS 下拉菜单仅保留 Project 同源的一套全局共享样式,移除 settingsStyle 切换;Project、Git Log 设置、Terminal、欢迎页及右键子菜单统一使用 8pt 外框圆角、1pt 内描边、24pt 行高、12.5pt 文字、6pt 外留白和 8pt 行内边距,设置选择下拉框共享同一外框与尺寸定义; Git Log 顶部按 IDEA 恢复 Branch: 已选分支名称,保留 Branch 筛选、清除和菜单内勾选;Branch、User、Date、Paths 按 IDEA FilterComponent 保留 4pt 内边距、3pt 箭头间距和 13pt 常规文字,四项文字 hover 为白色且不增加底色。Date 使用共享菜单呈现器,Branch/User 的可搜索列表与分组子菜单使用共享 LitheDropdownRowStyle、24pt 行高和共享菜单外框,Paths 输入面板复用共享菜单外框及搜索输入框样式,保留搜索、选项选中、路径 Apply/Cancel/Clear 和时间范围计算。 Branch/User/Paths 原生弹窗锚点不参与鼠标命中,核对按钮与文字收到 hover/点击、菜单正确弹出且关闭后可再次打开;Date 保持共享菜单呈现器。共享下拉外框裁剪内部内容,明暗主题下 Branch/User 搜索条不能覆盖圆角;Paths 输入框复用共享输入样式并显示正确焦点边框,Clear/Cancel/Apply 复用共享次按钮/主按钮,保留路径应用和清除行为。Branch、User、Paths 的可搜索内容同样使用 LitheContextMenuPresenter 的透明无边框面板,去掉 NSPopover 系统外框,复用 Project 共享背景、1pt 边框和圆角;分组展开与搜索动态调整弹窗尺寸,Esc、点击外部和切换菜单正确关闭。选择分支后清除筛选,确认 Branch 恢复默认标题且关闭按钮消失;关闭按钮保留 16pt closeSmall 明暗 SVG、22pt hover 区域及 4pt 圆角。展开分支树左侧固定线、中间分隔线和右侧详情拖动线均使用 toolWindowBorder 的 1pt 边框,右侧 hover 与拖动不高亮。右侧详情展示图标使用 IDEA previewVertically 明暗 SVG,装饰开关使用 show 眼睛而非 hide 工具窗口减号;所有工具栏展示图标复用 22pt、4pt 圆角的共享 hover 背景,保留原动作与提示。深浅主题渲染核对背景和边框,保留可选图标、勾选、快捷键、禁用、子菜单和键盘导航。 Git Log 提交标题、作者和日期使用 Inter 13 Normal,引用标签 12pt;核对至少 26pt 的字体度量行高、含 leading 的整数基线、长标题与中文/emoji 截断,以及选中合并提交的文字颜色。 所选分支存在时 Log 标签显示 Log: 分支短名(包含 codex/ 路径),选择另一分支立即更新;清除分支筛选后恢复 Log,保留本地化标题、原始分支名和原标签选择/焦点行为。 macOS 标题起点按缩放图谱列距、图文间距和 2pt 文字内边距计算,为对齐参考截图至少预留六列,密集行继续扩宽,不改变图谱节点、连线和箭头命中;在 SwiftUI/AppKit 两条渲染路径核对未选中的合并提交标题、作者、日期统一使用合并灰色,选中后恢复普通文字色,合并判定仅依赖至少两个父节点。 Git Log 右侧文件树使用共享 24pt 行高、19pt 层级缩进、常规字重、文件类型图标和 IDEA FileStatus 文件名颜色,无逐行横线及工具栏重复总数;核对文件夹折叠、文件打开 diff、长名称 hover 展开与横向滚动,拖动宽度不重建行内容。提交详情使用 14pt 外边距、10pt 内间距、等宽 bold 标题、同段作者邮箱时间与引用标签,长内容可滚动;文件树/详情之间分隔线复用共享边框,hover/拖动不高亮。左侧分支树从 180pt 专用下限改为共享 30pt 下限,独立 36pt 竖栏保持原宽,窄树裁剪后可拖回原宽,收缩/展开不重建图谱。 图谱圆点、连线、箭头按 SimpleGraphCellPainter/ PaintUtil 的 FLOOR/ODD 物理像素规则对齐;在 1×/2× 原生位图验证圆点与线宽,在真实 NSHostingView 检查对齐后的上下斜向箭头尖端及内侧描边可导航,切换屏幕只重绘且绘制/命中坐标一致。 时间列复用 Inter 13 常规字体的等宽数字与固定 AM/PM 区域,按应用语言使用英文 12h AM/PM 或中文 24h 格式;核对午夜、正午、未知日期回退、行起点一致及切换语言后的原生日期缓存失效,列表两条渲染路径和提交详情使用相同格式。 连续上下拖动 Git Log,包含上方长 Diff:原生列表只调整可见区域,保持文档身份、滚动位置、Shift/Command 多选、焦点色、键盘与箭头导航、分页和辅助功能;收缩再恢复后列表宽高正确。GitGraphInteractionTests 使用 300 条提交、80 次原生尺寸变化和共享分隔条事件验证,局部新旧路径计时作为诊断,不代替目标工作区帧率验证。 共享分隔容器按已有宽高直接分配两个面板与分隔条的确定矩形,避免 HStack/VStack 最小及理想尺寸探测;splitPaneBounds 覆盖横纵方向、leading/trailing、折叠、子视图身份和有限尺寸,达到尺寸限制后的相同结果不重复写状态。 通过键盘或程序化切换焦点时 Git 标签活动态双向更新;挂载或重新显示 Git Log 不主动抢走编辑器焦点。 在包含展开分支树的 Git Log 中连续拖动外层高度和内部宽度:共享分隔器只在拖动开始/结束传播环境状态,嵌套容器继承祖先状态;拖动期间共享行悬停不更新、提示不登记几何锚点、右键覆盖层暂停,松开后恢复菜单和提示,行内容及滚动状态不重挂载。69 行原生分隔条事件回归检查覆盖层暂停/恢复及内容身份;未悬停控件在平时同样不登记提示锚点。用目标工作区 Time Profiler/Animation Hitches 对照 Terminal 核对持续更新成本及起止开销,split.drag 整次手势时长不能当作单帧延迟;本次仅前端共享交互装饰调整,不修改后端及默认详情高度行为。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 四个筛选弹窗均以各自按钮左下角为定位锚点,Date 不依赖鼠标点击坐标;空间不足时限制在屏幕可见范围内。原生面板回归核对操作菜单/可搜索内容相同锚点下的左上角、无展开动画和 Esc 关闭回调。共享搜索字段在未聚焦时悬停也显示文本编辑光标,离开或禁用时恢复箭头。 Git Console 左侧六个现有动作使用 Community 原始 16pt 明暗 SVG;22pt 背景、竖向 2/1 按钮留白、5/7 外边距和默认箭头光标。核对查找、软换行、跟随、停止、清空、复制的原有动作与禁用状态,组件资源检查不替代完整应用验收。 按 Community CurrentBranchHighlighter 标出当前本地分支可达的所有父提交,包含合并父节点;深色 #1D2336、浅色 #EDF3FF,选中背景优先,仅已加载图谱可证明的祖先被标记,当前分支/HEAD 单独筛选时不重复染色。引用默认显示并按 GitRefManager/SimpleRefGroup 紧凑合组:本地与上游同提交显示 remote & branch,HEAD/本地/远端/标签使用官方黄/绿/紫/灰轮廓及叠层,标签单独存在时默认只显示图标;长名称按列宽和 22 字符下限缩写,原生可见区 tooltip 保留所有完整名称,滚动后按当前鼠标位置解析。明暗原生渲染检查颜色、宽度、选择优先级及完整名称;GitLogReferenceResizeTests 在真实 GitLogView 中加载 1000 个分支并拖动外层高度,检查可见右键覆盖层数量和挂载生命周期,计时仅作诊断。最新正式预览已进入该工作树,观察全部引用下的深色背景、叠层缩写标签和多次上边缘拖动后的布局恢复;用户随后在本机明确确认“现在不卡顿了”,本次报告的高度拖动卡顿通过用户实机验收;组件计时和混合工作区采样不用于推断唯一根因或量化通用帧延迟,其他主题/悬浮等视觉项继续按各自范围验收。 在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Commit files 树中过长的文件或目录名不折叠为省略号,区域随最宽行横向展开,可通过横向滚动条或触控板侧滑左右滑动查看完整名称(与源代码管理变更树一致);Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列将有 UTC 偏移的提交转换到本机时区后显示相对时间(刚刚、N 分钟前、今天/昨天);英文绝对日期和时间通过 Windows 原生接口遵循系统短日期/时间及用户自定义格式,中文使用中文地区格式,未知偏移仍显示绝对日期;验证美式月/日/年、英式日/月/年、自定义分隔符、12/24 小时制、跨时区相对时间和迟到原生结果保护,真实 WebView2 产品验收为 pending;鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 和提交标识,可通过文件导航分别查看;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 Windows 端提交历史仅通过 Git Log 工具窗浏览:源代码管理侧栏入口已更名为 Commit 面板,其 History 标签已移除,面板固定显示变更列表与提交区。命令面板中的 Git: Show History 入口同步移除。 Windows Commit Files 单文件预览使用独立页面:上下箭头导航差异,下箭头在当前文件最后一处后继续进入下一文件并在比较完成后定位第一处差异,整个文件集合末尾禁用且不循环;图片、二进制和无文本差异文件也允许下箭头继续,比较未就绪时禁用以防连续点击跳过文件。左右箭头直接切换当前比较集合的上一文件/下一文件;标签使用 IDEA 的 Repository Diff: 文件名并跟随导航更新,不附加提交 hash。Jump to Source 按最后聚焦侧的补丁位置打开预览所属仓库的文件,重命名使用新路径、删除行映射邻近保留行、已删除文件禁用源跳转;模型更新期间禁用差异动作。保留并排/统一视图、空白显示、Monaco 查找、图片和二进制呈现,普通全量提交比较仍使用多文件页面。真实 WebView2 内核及源文件打开还需目标产品运行验收。 Windows 单文件历史 Diff 外观验证:并排视图使用独立外侧 12px 滚动轨道、8px 滑块和概览,通常的统一视图保留 Monaco 原生 Diff 组件,整文件新增使用真实新行流的独立只读控件,深浅主题检查常态/悬停/拖动色、圆角及边框;实际拖动任一并排滑块后按差异边界同步对应行且模型身份保持。工具栏使用现有 Button,核对 40px 高度和 22px 图标按钮;保留用户字体与字号,默认行距按 14px 字号对应 22px 并随缩放,自定义行距不覆盖。词级高亮开关复用现有 Monaco 装饰能力,关闭只隐藏字符差异、保留整行差异,切换文件保留词级和空白开关。旧/新版本标题显示真实第一父提交、范围基线/目标或非连续提交各自身份,根提交旧侧为空树,重命名显示旧/新路径;短 hash 和截断路径的 tooltip 保留全文。并排标题列宽由独立布局所有者更新 CSS,拖动分隔条时标题跟随且不重建模型;统一视图标题纵向排列。以上浏览器真实组件验证不替代目标 WebView2 产品验收。 Windows 单文件 Diff 滚动条回归:两侧横纵滚动条默认显示,鼠标移到工具栏后仍可见,不依赖悬停;差异概览不绘制重复的宽矩形视口指示,并排由独立外侧轨道负责点击、拖动和滚轮,统一视图保留原生委托。明暗主题、并排/统一视图及长文本双向溢出下检查唯一可见滑块、并排横向同像素偏移同步、纵向对应行同步和模型身份。 历史 Diff 两侧滚动轨道与代码编辑器概览画布背景透明,鼠标不悬停时滑块仍显示;关闭代码编辑器重复概览通道,但保留 Diff 专用差异色条。横向轨道不额外增加内容高度,检查代码能显示在滑块后、底部内容与滚动位置正确,拖动命中保留,并排两侧横向同像素偏移同步、纵向对应行同步。 Windows 并排单文件 Diff 使用两个独立只读 Monaco 代码控件,取消 DiffEditor 的补空白对齐。左右横向滚动按同一像素偏移双向同步,较短侧由 Monaco 钳制到真实内容边界且不反向拉回主动侧;纵向原生代码区、外侧滑块拖动/点击、轨道键盘、滚轮和查找以视口三分之一处为锚点,按 Git 差异块起止边界同步对应行并保留行内偏移,短侧在不等长块内钳制、共同上下文恢复对齐。同步有重入与微小方向保护;双模型替换、布局、字体配置和显式上下导航期间暂停同步。新增旧侧/删除新侧画横跨可见代码区的双像素零行边界,修改区画上下单像素边界;两列行号背景为直边,曲线只在两列之间弯曲,实际偏移改变端点形状。检查文件开头/末尾零行、三态、明暗主题和窄列,代码不补空白、不扩展横向范围;连接面覆盖两列历史行号背景,行号选择和源映射正确。左右外侧各显示透明常显纵向滑块与增删改概览,横向保留原生滚动条,滚动范围截止真实最长代码行。验证深浅主题、折叠展开、Ctrl/Cmd+F 搜索折叠文本、词级开关和预算、统一视图及跨文件首处定位、连续拖动和窄窗口非负尺寸。高频帧二分筛选可见差异,缓存像素边界及概览,不扫描全文、不更新 React;卸载和迟到语言准备清理所有帧、监听、观察器、拖动、两个编辑器和模型。浏览器生产组件验证使用平台记录器,真实 WebView2 产品验收仍为 pending。 外侧轨道预留独立空间,不显示代码;左轨道距面板边缘、右轨道距代码视口各留 4px 间隙,窄列随可用宽度缩减,右侧文字与轨道不重叠;并排和统一预览关闭缩进及括号引导线,保留语法颜色;中央行号缩小边距和曲线间隙,整数布局边界及上下半像素描边消除与代码区的垂直接缝。滚轮在代码、行号和轨道统一加快到两倍,拖动仍映射各侧真实进度,纵向同步对应行;方向键按用户行高移动。有词级结果时修改正文底色按 40% 修改色与 60% 页面背景混合,完整修改色突出替换词的两侧,纯新增和纯删除词分别使用完整增删色,语法前景色和文字透明度保持;关闭词级或超限保留整行底色。验证三态与开头末尾、双向对应行同步、明暗主题、轨道无遮挡、宽窄字体布局和预算回退。 单文件并排及统一视图隐藏补丁 @@ 标题,但保留真正以 @@ 开头的源码、行 ID、源行映射和原始补丁;默认多文件与暂存投影不变。Islands Dark 继承 Darcula 背景,Islands Light 使用 Light 方案,独立差异轨道标记与连接、代码边界共用对应差异背景色。底部保留五个用户实际行高的 padding,模型不增加假行;从任意侧滚到底部验证共同末行对齐与五行留白,字号/行距变化同步更新。 Windows 单文件 Diff 首次打开、左右切文件和同路径新比较均在模型/比较准备完成后定位第一处改动;定位只消费一次,调整词级、空白、字号或主题保持用户当前位置。统一视图的原生 Monaco 增删行、字符与行号栏使用该预览相同的 Islands 背景变量,移除默认字符边框;新增整文件应为深色 #294436 或浅色 #BEE6BE,删除为 #484A4A 或 #D6D6D6,语法前景色不变且其他编辑器主题不被覆盖。 整文件新增采用单流只读代码控件,隐藏不存在的旧侧与连接带,新增背景覆盖行号、代码和底部留白,避免空旧模型的灰色删除行及外圈;新增仅显示新版本、删除仅显示旧版本的一组 hash 与路径。并排两侧及新增单流轨道的增删改标记与对应内容背景共用颜色;原生统一预览关闭 renderIndicators,历史行号旁不显示 +/-。这组新增显示修复按用户要求延后运行验证,提交或 PR 前核对新增文件、普通修改、删除、深浅主题、查找、源跳转与底部留白。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 右下角 Commit files 列表同样跟随 Diff 的上一/下一文件导航:选中行移到当前文件,被折叠的父目录自动展开,行滚动到可见位置;预览属于其他提交或范围时不改变选中,点击行不会改变横向滚动。 Windows Git Log 筛选后基于完整已加载历史投影可见祖先:隐藏中间提交以虚线连接,直接父子保持实线,无关分支不误连;合并路径去重并保留永久颜色,未加载父提交经隐藏节点传播,分页补齐后解除缺失标记;清空筛选恢复原图,核对文本、作者、分支名称三种范围、空结果与选择行为。算法和真实表格组件回归通过不代替 WebView2 原生验收,原生验证保持 pending。 Windows 未筛选图与筛选图共用上下半边打印元素:普通浏览的跨行泳道换列必须在共同中点连续连接,合流入边保留各自颜色到提交圆点;核对原始 IDEA 200 条历史输出的列号、方向、终止和颜色身份;截图/原生交互验收仍为 pending,组件输出和 golden 对照不代替 WebView2 验收。 Windows Git Graph 外观:核对明暗主题真实 IDEA RGB、实心节点、直线与筛选虚线、终止箭头及至少六列标题预留;默认 26px 行高,用户字号变化时虚拟行与 SVG 同步扩高;在 100%/125%/150%/200%/300% 缩放及跨屏 DPI 变化下核对奇数物理像素和相邻半边连续性,普通视口调整不重建历史图。箭头能跳到已加载且可见的父/子提交,未加载父节点没有可点击目标;浏览器生产组件验证不代替目标 WebView2 运行验收,原生验证保持 pending。 Windows 历史排序和图导航:在交错分支仓库核对首批/分页/回退及全引用上下文均使用日期顺序,切换分支保持可证明的永久颜色和基础顺序;上下文最多 5000 条,覆盖不完整或哈希重复时保留可见页并回退。核对 upstream/backup 等非 origin 远程引用分类及本地含斜杠分支;背景上下文不阻塞 50 条可见页,分页不取消或重读上下文,切仓库/刷新/卸载取消旧操作且关闭迟到游标。长边开关展开/收起不改变提交、筛选、选择和游标;验证 30/1000 行边界。点击或用 Enter/空格激活上下箭头,选择并滚动到屏幕外端点,挂载后键盘焦点随目标;不误选源行、不误开源行 Diff,保持普通选择/多选/双击/右键和原预览入口。明暗主题及 1x/1.25x/2x 检查箭头尖端与命中范围,真实 WebView2 运行验收保持 pending。 图谱颜色必须跟随 ThemeRegistry 根 data-theme-type 切换;不依赖页面存在 dark class。复用真实 Lithe 明暗主题检查同一已挂载节点与连线的颜色变化、布局不因主题重算,结合主题回归和浏览器真实切换验证。 Windows 分页稳定性:选中顶部提交后向下滚动触发连续分页,确认滚动位置不跳回已选提交,Commit Files 不清空或重读,已选文件及树折叠保持;更新引用、背景上下文和相同 hash 的元数据同样不导航。定位分支头通过独立请求仍能定位已经选中的头提交一次;新选择或切仓库仍读取新文件并丢弃旧结果。组件回归与真实浏览器虚拟列表/IntersectionObserver 验证通过,目标 WebView2 实机验收保持 pending。 Windows Git Log 标题栏不显示固定 Read-only/只读文字,关闭按钮保持靠右,刷新、设置及隐藏操作正常。 Windows Git Log 单个提交右键 New Tag… 输入非空且无空白的标签名,创建指向右键提交完整 hash 的轻量标签并刷新引用和历史;取消不写入,失败保留名称可重试,双击/Enter 不重复创建,多选和写操作期间禁用。仓库或工作区切换关闭旧弹窗,旧请求迟到不得刷新或关闭新弹窗。 Windows 提交区域点击后上下箭头更新可见提交选择和详情,焦点不随虚拟行移除而丢失;筛选、首尾边界、Home/End、Shift 范围选择和 Enter 正常,菜单与输入框保留自己的方向键。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 Windows Git Log 提交右键 New Tag 使用与 Tags 节点同源的 TagIcon(沿用现有标签轮廓图案),核对 16px 图标、菜单对齐和禁用反馈;新补图标的实机视觉确认待完成。 Windows 提交列表菜单键和 Shift+F10 打开当前选中提交的真实菜单;关闭后列表继续上下键导航,菜单操作仍针对所选提交。New Tag 在 tag.gpgSign=true 下生成轻量标签,不运行签名器或消息编辑器,不修改配置。 Windows 提交行高亮和紧凑引用组:在全部引用和其他分支视图核对当前检出分支全父祖先背景,当前分支/HEAD 视图关闭重复高亮;合并提交的说明、作者、日期未选中时弱化、选中后恢复,选中背景优先于分支并区分列表/文件树/窗口焦点。核对明暗主题固定基准色、真实 upstream 合并名称、游离 HEAD、每种最多两个叠加图标、仅标签图标和完整汇总悬停/无障碍提示。宽窄列及字号变化时按至少 22 字符缩略规则布局,只有一个视口尺寸观察器,列拖动不逐行读取宽度,卸载清理;分页不丢滚动/选择,成功后不重读 Commit Files;文件读取失败时 Commit Files 面板显示重试入口,重新选择同一提交或刷新历史也会重试一次,长边导航和新建标签原入口保持。组件与浏览器验证通过,目标 WebView2 实机验收 pending。 Windows Git Log 分支树和提交列表的普通滚轮使用 Community Windows 默认 200ms 和 (0.5,0.505,0.5,1) 曲线;连续同向累积、反向从当前位置重启。核对两处列表的中间帧、Shift 横向滚动、分页保持、顶底边界、键盘定位和滑块拖动取消惯性、关闭工具窗清理帧以及减少动态效果偏好。确定性动画与 DOM 回归通过,真实 WebView2 设备手感验收保持 pending。 Windows graph 标题不再固定预留六列:建议宽度取可见图边数的加权均值与标准差,最多以六列作统一基线,每行按节点与斜边中点扩展,密集行不把其他简单行扩成最大列宽。验证单线/少分支、罕见密集合并、超过六列、筛选、长边开关、引用标签可用宽度及字号/DPI,保持图谱颜色/连线/导航不变。 Windows Git Log 的 Commit Files 文件树沿用 IDEA Windows 默认 200ms 滚轮动画;在文件行、竖向/横向滚动条区域连续滚动、反向及 Shift 横向滚动,确认共用真实视口的动画目标,长文件名保持自然宽度;按下滚动条取消未完成惯性,减少动态效果时直接滚动,切换提交及卸载不残留帧。其它 ScrollArea 默认行为不变。真实 WebView2 交互验收 pending。 Windows Git Log 右键 New Tag 打开弹窗后焦点直接位于标签名输入框,无需点击即可输入,Enter 提交、Esc 关闭。 macOS Git Log 空状态:在非 Git 项目检查明暗主题、窄窗口和键盘操作:13pt 常规文字按 IDEA 左对齐,整组位于内容区上方三分之一处;显示 To track changes to code、GitHub/GitLab 官网链接、Initialize Git repository…、Open Git Settings 和带原始 ContextHelp 图标的 Version control integration(打开 Git 官方文档)。初始化仍先确认,不自动暂存或提交;设置打开既有 Git 设置。未提交仓库、无匹配提交、加载失败及重试保持原动作,所有空状态操作采用文字按钮。 非 Git 项目空状态隐藏 Log/Worktrees/Console 标签、添加按钮、分支树与动作条、筛选/搜索工具栏及提交详情,提示占满工具窗内容区;GitHub、GitLab、初始化、设置及帮助链接悬停仅给当前文字加主题强调色下划线并显示手形,离开后恢复;初始化或切换至真实仓库后恢复原有三栏布局,真实仓库无匹配提交仍保留筛选入口。 空状态字体与间距对照 Community c7f91397:按钮标签显式使用 Inter 13pt Regular;行首破折号按首行基线对齐,21pt 行高加 2pt 间隔,帮助图标间隔 2pt;灰字与链接分别沿 Component.infoForeground 和 Islands Link.activeForeground 核对明暗主题,悬停下划线仍使用当前主题强调色。 macOS 右侧提交详情的分支标签按可见区域懒加载,全部引用仍可滚动查看。重开面板初始化时恢复匹配的提交图和分支树,第一次布局即可读取全部已缓存数据,不依赖出现后的异步任务回填。重开面板复用未变化的分支树,单仓库不重复构建分组副本,多仓库活动根复用已有三类行;引用、仓库、工作树开关、折叠状态改变后拒绝旧树。原生绘制与无障碍日期文本复用缓存,语言改变后刷新。 macOS 大量引用首屏:同提交放置 1,500 个本地分支和 1,500 个远程引用,核对真实 upstream 优先、同名远程自然排序回退、完整提示及每类图标上限;反复切换 Terminal/Git Log,数据版本相同应复用已准备图,改变分支/筛选/图显示选项后不得复用旧图。运行 GitGraphInteractionTests 的 manyReferenceGroups 和 presentationCache;组件耗时仅诊断,整机卡顿以相同数据实测为准。 macOS Git Log 及终端按需创建后由工作台持有实际原生宿主;隐藏再重开及移动停靠位置复用控件。运行 GitLogReferenceResizeTests,验证同一工作台隐藏再重开时,固定原生表面上的提交文档和滚动容器对象身份不变,恢复可见性且尺寸正确,工作台释放后宿主释放;核对语言、主题、搜索、分支操作和终端会话。 Git Log 与终端同时停靠时,从外层横向和纵向分隔条中央及向面板两侧偏移 4pt 处,经窗口发送按下、拖动、松开,确认完整 10pt 命中区仍能提交尺寸、宿主身份保持,面板中央正常交互。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 Windows HEAD 按 Community HeadNodePainter 核对外圈、背景间隔和中心实点:26px 行高、100% 缩放时三层直径为 13/9/5px,125%/150%/200%/300% 按物理像素 FLOOR/ODD 对齐;深浅主题、悬停、当前分支及活动/非活动选择均透出真实行底色,穿过 HEAD 的连线不侵入间隔。普通节点及分叉、筛选、长边展开保持;同边双向终止元素键唯一。目标 WebView2 实机验收保持 pending。 macOS 在明暗主题的窄 Git Log 工具栏中选择长用户名、分支和路径,确认名称与选中值以同一行尾部截断,文字不重叠,清除按钮和重新选择仍可用。 macOS Git Log 接近底部时自动追加较早提交,正常浏览不出现必须点击的加载更多按钮;单一页面最多发起一次边缘请求,追加保留选择和原生视口偏移。无新提交或缺失后续游标时停止并释放游标;失败暂停自动读取,显式刷新重开历史而不重用失败游标。运行 GitGraphInteractionTests.automaticHistoryPaging 与 GitModuleTests.continuousHistoryPagingStopsOnDuplicatePagesAndRefreshesAfterFailure,真实大仓库连续滚动和 Windows WebView2 验收仍 pending。 macOS 深层历史后台刷新:文件状态变化不重开首批,元数据刷新保留已加载窗口和后续页占位,未变结果不重建提交图;外部新提交按可见 hash 与行内偏移保持原生视口,按新行位移补齐旧尾部,保留尾部选择。当前仓库引用只读取一次,多仓库仍独立加载。后台刷新打断自动分页后,即使历史相同也能继续读取新游标;失败仍暂停自动重试。运行 GitModuleTests.backgroundRefreshRetainsDeepHistoryAndSelectionWhileExternalCommitsStillAppear、引用聚合回归及 GitGraphInteractionTests.backgroundHistoryRefreshAnchorsVisibleCommitInsteadOfSelectedCommit/automaticHistoryPaging;真实大仓库长时间滚动仍待用户验收,原生进程 cleanup 超时未独立复现,不将减少重复查询等同于修复所有进程失败。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。 历史菜单资格使用 Core 读取真实 HEAD 与第一父链;在筛选/分页、旁支首行、跳选、切换仓库、HEAD 变化和关闭菜单时验证动作状态及迟到结果隔离;两端原生界面验收 pending。 Git 控制台执行日志的记录状态(completed / unconfirmed)、单条与总量保留预算和脱敏由 Core 事件携带,两端不再各自归约或二次脱敏,单条输出预算统一为 32768 字符。",
"notes": "多仓库颜色槽位由 Core 按完整顺序分页计算;两端只映射色板,隐藏工作树不改变其他仓库颜色,迟到的结果被丢弃。 Windows 两处单文件 Diff 共用工具栏:显示选项移到左侧,右端显示 IDEA 差异统计及 Commit included 数;英文文案、单复数和空白符稳定开关名参考 Community 源码。组件回归覆盖统计及开关行为,原生视觉验收 pending。 Windows 上下差异箭头补齐双向跨文件导航;Commit 列表从活动工作区预览的仓库/路径同步行选择和一次性虚拟滚动,沿用原有加载代次保护。组件回归覆盖双向边界、嵌套仓库、折叠展开、刷新及焦点/include 保持,完整原生验收 pending。 Windows 筛选图沿用 macOS/Community 双向最近可见祖先算法和隐藏边界分组;通过统一打印元素使用保留边身份的 SVG 半边绘制,未筛选和筛选共用同一绘制入口。 Windows 移除未筛选 SVG 的泳道数组推断路径,统一消费 printElements;图宽计入斜向边界中点;外观采用 macOS/Community 完整颜色生成、实心点、直线、方向箭头和字号/DPI 绘制尺寸。 Windows Git Log 显式请求 Core 既有 date 顺序,并独立加载有界仓库上下文,在唯一且完整覆盖当前页时复用永久布局;远程引用身份来自真实引用集合。长边默认紧凑,可展开并保留安全上限,箭头透明按钮复用物理像素几何和既有选择/虚拟滚动。焦点由目标 DOM 挂载消费,拒绝替换提交或筛选的旧请求,不轮询。 Windows 将图谱数据重绘与选择/明确导航区分,分页不再触发选择定位;Inspector 按仓库与实际比较身份读取文件,保留文件树和预览代次保护;只有失败的读取可重试(失败身份与当前选择一致时才重试,避免旧失败让新选择重复加载)。 Windows 按 macOS/Community 补齐当前分支范围、合并文字和活动/非活动选择;引用由独立纯分组模型准备,共享视口字体/宽度度量绘制原始 TagPainter 图标,分组不改变真实引用。 Windows Git Log 两处滚轮显式启用共享动画绑定;每帧只更新视口,保留虚拟列表和既有事件换算,其他滚动区默认行为保持。 Windows 移除固定六列最小标题留白,按 Community PrintElementGeneratorImpl 和 GraphCommitCellUtil 的建议宽度及逐行占用混排图形与说明;真实 IDEA oracle 的 Width 输出也参与回归。macOS 保留已有标题留白,本次只调整 Windows。 Windows 连线改用单一连续 SVG,沿完整边身份拼接相邻半边;HEAD 采用 Community 独立尺寸和外圈加实点,裁掉圆内连线使背景间隔不依赖行状态副本。 连续图层只为虚拟列表及上下各一行生成节点、路径和 HEAD 裁剪,SVG 高度随窗口而非全部历史增长,按全局行偏移定位且边身份稳定;5,000 条历史、四行视口及换列分叉滚动边界有确定性回归,空虚拟范围不绘制。真实 WebView2 滚动性能仍待验收。 Git Log 工具栏按 IDEA 调整:文本筛选框缩短为固定宽度,输入后按 Enter 才搜索(清除按钮立即清空,输入法组词期间的 Enter 不触发);在文本筛选右侧新增 Branch 下拉,可搜索并按 Local/远程分组选择分支(不含 Tags),选中分支后右侧出现 × 一键回到 All;展开的下拉在鼠标位于菜单中部时也能用滚轮上下滚动(WebView2 滚轮粘连到被裁剪子元素的问题,由通用 Dropdown 把滚轮应用到自身滚动容器);所选分支按仓库保存,重新打开项目后恢复,分支已被删除时回退为 All。组件回归覆盖 Enter 搜索、下拉选择、按仓库持久化和分组排序,WebView2 实机验收 pending。 Windows 提交详情双栏 diff 的正文首列与中央 tearline 竖线保留 1px 间距(lineDecorationsWidth: 1),文字不再贴线;行号列与连接带几何不变。 macOS 修复 #1342:Git Log Branch/User/Date/Paths 的名称与选中值共用单段文字布局,保留各自颜色,窄宽度统一尾部截断,避免独立 Text 压缩导致重叠。 macOS Git 主日志新增原生边缘自动加载,保留有界后端分页和可见区域绘制;Windows 已有 IntersectionObserver 自动加载入口,本次未改动。 macOS 后台状态与历史上下文刷新分开,元数据检查原子替换已加载窗口,原生视口按可见提交锚定;复用当前仓库引用结果,避免重复读取。"
}
diff --git a/windows/tauri/src/features/git/services/git-execution-journal.test.ts b/windows/tauri/src/features/git/services/git-execution-journal.test.ts
index fafa90763e..edda2cfdcb 100644
--- a/windows/tauri/src/features/git/services/git-execution-journal.test.ts
+++ b/windows/tauri/src/features/git/services/git-execution-journal.test.ts
@@ -5,6 +5,15 @@ import type { GitExecutionEvent } from "@/platform/git-execution-events";
import { describe, expect, test } from "bun:test";
import { GitExecutionJournal, gitConsoleCommand, gitConsoleConfiguration, gitConsoleTimestamp } from "./git-execution-journal";
+const retention = fixture.events[0].retention as NonNullable;
+/** A journal that has seen Core's budget, as every real session has. */
+function journalWithRetention(now: () => number = () => 0) {
+ const journal = new GitExecutionJournal(now);
+ journal.receive({ operationId: "budget", type: "requestStarted", retention });
+ journal.receive({ operationId: "budget", type: "requestFinished", unconfirmedInvocations: [], failureRecord: false });
+ return journal;
+}
+
describe("Git execution journal", () => {
test("shared lifecycle keeps preflight silent and suppresses cleared requests", () => {
for (const sample of lifecycle.cases) {
@@ -25,12 +34,12 @@ describe("Git execution journal", () => {
}
});
test("silent queries never consume or evict the full visible command history", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (let index = 0; index < 200; index++) {
const operationId = `command-${index}`;
journal.receive({ operationId, type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["commit"] });
- journal.receive({ operationId, type: "finished", invocationId: 1, exitCode: 0 });
- journal.receive({ operationId, type: "requestFinished" });
+ journal.receive({ operationId, type: "finished", invocationId: 1, exitCode: 0, state: "completed" });
+ journal.receive({ operationId, type: "requestFinished", unconfirmedInvocations: [], failureRecord: false });
}
const retained = journal.records;
const originalIds = retained.map((record) => record.id);
@@ -45,11 +54,11 @@ describe("Git execution journal", () => {
expect(journal.active.size).toBe(0);
});
test("failed preflight retains its reason and directory without claiming Git started", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (let index = 0; index < 201; index++) {
const operationId = `failed-${index}`;
journal.receive({ operationId, type: "requestStarted", workingDirectory: "C:/denied", action: "git_fetch" });
- journal.receive({ operationId, type: "requestFinished", error: { message: "Could not open repository", details: "Permission denied" } });
+ journal.receive({ operationId, type: "requestFinished", failureRecord: true, error: { message: "Could not open repository", details: "Permission denied" } });
}
expect(journal.records).toHaveLength(200);
expect(journal.records[199]).toMatchObject({ root: "C:/denied", action: "git_fetch", arguments: [], state: "unconfirmed",
@@ -59,14 +68,14 @@ describe("Git execution journal", () => {
expect(journal.active.size).toBe(0);
});
test("all operation sources share history before a console subscribes", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (const command of ["add", "commit", "checkout", "push", "stash", "rebase", "worktree"]) {
journal.receive({ operationId: command, type: "requestStarted", workingDirectory: "C:/repo" });
journal.receive({ operationId: command, type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: [command] });
}
for (const command of ["push", "add", "commit", "checkout", "stash", "rebase", "worktree"]) {
journal.receive({ operationId: command, type: "output", invocationId: 1, stream: "stdout", text: command });
- journal.receive({ operationId: command, type: "finished", invocationId: 1, exitCode: 0 });
+ journal.receive({ operationId: command, type: "finished", invocationId: 1, exitCode: 0, state: "completed" });
journal.receive({ operationId: command, type: "requestFinished" });
}
expect(journal.records.map((record) => record.arguments[0])).toEqual(["add", "commit", "checkout", "push", "stash", "rebase", "worktree"]);
@@ -104,7 +113,7 @@ describe("Git execution journal", () => {
expect(journal.records[0].output).toBe("Receiving: 100%\nreference updated\n");
});
test("empty output rows stay bounded without losing recent interleaved streams", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
journal.receive({ operationId: "fetch", type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["fetch"] });
for (let index = 0; index < 2_100; index++) {
journal.receive({ operationId: "fetch", type: "output", invocationId: 1, stream: "stdout", text: "" });
@@ -138,12 +147,12 @@ describe("Git execution journal", () => {
journal.receive({ operationId: "new", type: "requestFinished" });
});
test("progress on stderr does not make a successful command fail", () => {
- const journal = new GitExecutionJournal();
+ const journal = journalWithRetention();
journal.receive({ operationId: "fetch", type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["fetch"] });
journal.receive({ operationId: "fetch", type: "output", invocationId: 1, stream: "stderr", text: "Receiving: 50%", progress: true });
expect(journal.records[0].progress).toBe("Receiving: 50%");
expect(journal.records[0].output).toBe("");
- journal.receive({ operationId: "fetch", type: "finished", invocationId: 1, exitCode: 0, durationMilliseconds: 42 });
+ journal.receive({ operationId: "fetch", type: "finished", invocationId: 1, exitCode: 0, state: "completed", durationMilliseconds: 42 });
expect(journal.records[0].state).toBe("completed");
expect(journal.records[0].error).toBeUndefined();
expect(journal.records[0].durationMilliseconds).toBe(42);
@@ -163,19 +172,19 @@ describe("Git execution journal", () => {
expect(journal.records).toEqual([]);
expect(journal.active.size).toBe(0);
});
- test("output is bounded and preview formatting does not expose URL credentials", () => {
- const journal = new GitExecutionJournal();
+ test("output is bounded by Core's budget and preview formatting does not expose URL credentials", () => {
+ const journal = journalWithRetention();
journal.receive({ operationId: "fetch", type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["fetch"] });
journal.receive({ operationId: "fetch", type: "output", invocationId: 1, text: "x".repeat(100_000) });
expect(journal.records[0].truncated).toBe(true);
- expect(journal.records[0].output.length).toBeLessThanOrEqual(65_536);
+ expect(journal.records[0].output.length).toBeLessThanOrEqual(retention.maxRecordOutputCharacters);
expect(gitConsoleCommand(["fetch", "https://user:fake@example.invalid/repo"])).not.toContain("fake");
});
});
test("discarded execution history is marked separately from foldable retained output", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (let index = 0; index < 205; index += 1) {
journal.receive({ operationId: `query-${index}`, type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["status"] });
}
diff --git a/windows/tauri/src/features/git/services/git-execution-journal.ts b/windows/tauri/src/features/git/services/git-execution-journal.ts
index 173616e5d5..49b58ec770 100644
--- a/windows/tauri/src/features/git/services/git-execution-journal.ts
+++ b/windows/tauri/src/features/git/services/git-execution-journal.ts
@@ -1,4 +1,4 @@
-import type { GitExecutionEvent } from "@/platform/git-execution-events";
+import type { GitExecutionEvent, GitJournalRetention } from "@/platform/git-execution-events";
export interface GitConsoleRecord {
id: string;
@@ -26,12 +26,12 @@ export interface GitConsoleRecord {
error?: string;
}
-const MAX_RECORDS = 200;
-const MAX_OUTPUT = 65_536;
-const MAX_OUTPUT_LINES = 2_000;
-const MAX_TOTAL_OUTPUT = 1_048_576;
-
-/** Receives complete sanitized native lines. State is intentionally memory-only. */
+/**
+ * Stores the ordered native events Core emits. State is intentionally memory-only.
+ *
+ * Core owns record states, redaction, and the retention budget (carried by
+ * `requestStarted`); the journal only stores, trims to that budget, and exposes records.
+ */
export class GitExecutionJournal {
constructor(private readonly now: () => number = Date.now) {}
records: GitConsoleRecord[] = [];
@@ -41,6 +41,7 @@ export class GitExecutionJournal {
private hidden = new Set();
private pending = new Map();
private sequence = 0;
+ private retention: GitJournalRetention | null = null;
clear(root?: string) {
this.records = this.records.filter((record) => root != null && !sameGitRoot(record.root, root));
@@ -53,6 +54,7 @@ export class GitExecutionJournal {
if (event.type === "started") this.sequence += 1;
if (event.type === "authentication") { this.authentication.push(event); return; }
if (event.type === "requestStarted") {
+ if (event.retention) this.retention = event.retention;
this.active.set(operationId, event.workingDirectory ?? "");
this.pending.set(operationId, { root: event.workingDirectory ?? "", action: event.action ?? "Git" });
return;
@@ -66,15 +68,15 @@ export class GitExecutionJournal {
const records = this.records.filter((record) => record.operationId === operationId);
let last = records[records.length - 1];
// A failed preflight needs its reason, but is never presented as an executed command.
- // A request cancelled before any command ran (a newer request superseded it) is not a failure.
- if (!last && event.error && event.error.code !== "cancelled") {
+ if (!last && event.failureRecord && event.error) {
last = { id: operationId, operationId, timestamp: this.now(), root: pending?.root ?? event.workingDirectory ?? "",
action: pending?.action ?? event.action ?? "Git", arguments: [], state: "unconfirmed", output: "", lines: [], truncated: false };
this.records.push(last);
}
- if (last && event.error) last.error = redactConsoleText([event.error.message, event.error.details].filter(Boolean).join("\n"));
+ if (last && event.error) last.error = [event.error.message, event.error.details].filter(Boolean).join("\n");
+ const unconfirmed = new Set(event.unconfirmedInvocations ?? []);
for (const record of records) {
- if (record.state === "running" || record.state === "queued") record.state = "unconfirmed";
+ if (unconfirmed.has(Number(record.id.slice(operationId.length + 1)))) record.state = "unconfirmed";
}
this.trimHistory();
return;
@@ -82,7 +84,7 @@ export class GitExecutionJournal {
if (this.hidden.has(operationId)) return;
if (event.type === "remoteResult") {
const record = [...this.records].reverse().find((record) => record.operationId === operationId && (event.invocationId == null || record.id === `${operationId}:${event.invocationId}`));
- if (record) { record.remoteResult = event; if (event.error) record.error = redactConsoleText(event.error.message); }
+ if (record) { record.remoteResult = event; if (event.error) record.error = event.error.message; }
return;
}
const id = `${operationId}:${event.invocationId}`;
@@ -96,34 +98,39 @@ export class GitExecutionJournal {
const record = [...this.records].reverse().find((record) => record.id === id);
if (record && event.type === "output") {
record.phase = event.progressDetails ?? record.phase;
- if (event.progress) record.progress = redactConsoleText(event.text ?? "");
+ if (event.progress) record.progress = event.text ?? "";
else {
- const text = redactConsoleText(event.text ?? "");
- record.lines.push({ stream: event.stream === "stderr" ? "stderr" : "stdout", text: text.slice(-(MAX_OUTPUT - 1)) });
- let characters = record.output.length + record.lines[record.lines.length - 1].text.length + 1;
- while (record.lines.length > 1 && (characters > MAX_OUTPUT || record.lines.length > MAX_OUTPUT_LINES)) {
- characters -= record.lines.shift()!.text.length + 1;
- record.truncated = true;
+ const limits = this.retention;
+ const text = limits ? (event.text ?? "").slice(-(limits.maxRecordOutputCharacters - 1)) : (event.text ?? "");
+ record.lines.push({ stream: event.stream === "stderr" ? "stderr" : "stdout", text });
+ if (limits) {
+ let characters = record.output.length + text.length + 1;
+ while (record.lines.length > 1 && (characters > limits.maxRecordOutputCharacters || record.lines.length > limits.maxRecordOutputLines)) {
+ characters -= record.lines.shift()!.text.length + 1;
+ record.truncated = true;
+ }
+ record.truncated ||= (event.text ?? "").length >= limits.maxRecordOutputCharacters;
}
- record.truncated ||= text.length >= MAX_OUTPUT;
record.output = record.lines.map((line) => line.text + "\n").join("");
record.progress = undefined;
}
record.truncated ||= !!event.truncated;
}
if (record && event.type === "finished") {
- record.state = event.exitCode == null ? "unconfirmed" : "completed";
+ record.state = event.state ?? "unconfirmed";
record.exitCode = event.exitCode ?? undefined;
record.expectedExit = event.expectedExit ?? false;
record.durationMilliseconds = event.durationMilliseconds;
- record.error = event.error ? redactConsoleText([event.error.message, event.error.details].filter(Boolean).join("\n")) : undefined;
+ record.error = event.error ? [event.error.message, event.error.details].filter(Boolean).join("\n") : undefined;
record.progress = undefined;
}
this.trimHistory();
}
private trimHistory() {
- while (this.records.length > MAX_RECORDS || (this.records.length > 1 && this.records.reduce((sum, record) => sum + record.output.length, 0) > MAX_TOTAL_OUTPUT)) {
+ const limits = this.retention;
+ if (!limits) return;
+ while (this.records.length > limits.maxRecords || (this.records.length > 1 && this.records.reduce((sum, record) => sum + record.output.length, 0) > limits.maxTotalOutputCharacters)) {
this.historyTruncated = true;
this.records.shift();
}
diff --git a/windows/tauri/src/platform/git-execution-events.ts b/windows/tauri/src/platform/git-execution-events.ts
index 3ad08aeb37..36bef53f76 100644
--- a/windows/tauri/src/platform/git-execution-events.ts
+++ b/windows/tauri/src/platform/git-execution-events.ts
@@ -1,5 +1,12 @@
export type GitExecutionSource = "user" | "background" | "unknown";
+export interface GitJournalRetention {
+ maxRecords: number;
+ maxRecordOutputCharacters: number;
+ maxRecordOutputLines: number;
+ maxTotalOutputCharacters: number;
+}
+
/** Native diagnostics only; command input and environment are never included. */
export interface GitExecutionEvent {
operationId: string;
@@ -22,6 +29,14 @@ export interface GitExecutionEvent {
displayArguments?: string[];
globalArguments?: string[];
progressDetails?: { stage: string; percent?: number | null; completed?: number | null; total?: number | null };
+ /** `requestStarted`: the console journal budget Core defines for every host. */
+ retention?: GitJournalRetention;
+ /** `finished`: the record's final state, decided by Core. */
+ state?: "completed" | "unconfirmed";
+ /** `requestFinished`: records of this request whose exit was never observed. */
+ unconfirmedInvocations?: number[];
+ /** `requestFinished`: the failure needs its own record because no Git process started. */
+ failureRecord?: boolean;
requestId?: string;
prompt?: string;
secret?: boolean;
From ddb8c13aef6f450ce6b79870b74d1d9c18c974a5 Mon Sep 17 00:00:00 2001
From: lick <2188718831@qq.com>
Date: Thu, 5 Nov 2026 01:09:09 -0800
Subject: [PATCH 7/7] =?UTF-8?q?test(git):=20#1662=20GIT-28=20=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E9=9D=A2=E6=9D=BF=E6=B5=8B=E8=AF=95=E6=9B=BF=E8=BA=AB?=
=?UTF-8?q?=E8=A1=A5=E4=B8=8A=20git.normalizeChangeTargets=EF=BC=8C?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=90=E4=BA=A4=E5=87=86=E5=A4=87=E7=9B=B8?=
=?UTF-8?q?=E5=85=B3=E7=94=A8=E4=BE=8B=E5=A4=B1=E8=B4=A5=E4=B8=8E=E5=A5=97?=
=?UTF-8?q?=E4=BB=B6=E8=B6=85=E6=97=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
提交/暂存流程现在先经 Core 归一化目标;测试里该命令落到真实 Tauri invoke 而失败。替身按共享 fixture 的规则返回索引,断言与超时均未放宽。
Co-Authored-By: Claude Opus 5.5
---
.../git-workspace-status-panel.test.tsx | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
index be928b3cad..5c54e7a662 100644
--- a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
+++ b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
@@ -70,6 +70,25 @@ beforeEach(() => {
staging = spyOn(statusApi, "setFilesStaged").mockResolvedValue(true);
const originalInvoke = core.invoke;
colorInvoke = spyOn(core, "invoke").mockImplementation(((command, args, options) => {
+ if (command === "git.normalizeChangeTargets") {
+ // Stands in for Core, whose own tests and shared fixture define the rule:
+ // drop rows listed below a selected untracked directory summary of the same repository.
+ const { targets } = args as {
+ targets: Array<{ root: string; path: string; untrackedDirectory?: boolean }>;
+ };
+ const indices = targets.flatMap((target, index) =>
+ targets.some(
+ (summary) =>
+ summary.untrackedDirectory &&
+ summary.root === target.root &&
+ summary.path !== target.path &&
+ target.path.startsWith(summary.path),
+ )
+ ? []
+ : [index],
+ );
+ return Promise.resolve({ indices });
+ }
if (command !== "git.repositoryColorAssignments") return originalInvoke(command, args, options);
const page = args as { offset: number; totalCount: number; repositoryIds: string[] };
return Promise.resolve({ assignments: page.repositoryIds.map((id, index) => ({ id, slot: (page.offset + index) % 8 })), fallbackSlot: 0, showColors: page.totalCount > 1 });