Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -1491,6 +1492,7 @@
"Enter a ChangeList name (1–100 characters)." = "Enter a ChangeList name (1–100 characters).";
"Enter a JSON object of adapter arguments no larger than 256 KiB." = "Enter a JSON object of adapter arguments no larger than 256 KiB.";
"Enter a JVM debug port between 1 and 65535." = "Enter a JVM debug port between 1 and 65535.";
"Enter a Squash message or use the default combined messages." = "Enter a Squash message or use the default combined messages.";
"Enter a branch name" = "Enter a branch name";
"Enter a branch name before publishing." = "Enter a branch name before publishing.";
"Enter a branch name." = "Enter a branch name.";
Expand Down Expand Up @@ -1541,6 +1543,7 @@
"Evaluating…" = "Evaluating…";
"Event" = "Event";
"Events over time" = "events over time";
"Every Reword step needs a commit message." = "Every Reword step needs a commit message.";
"Every saved version of this project's files is deleted. Open files are not changed." = "Every saved version of this project's files is deleted. Open files are not changed.";
"Example" = "Example";
"Exception" = "Exception";
Expand Down Expand Up @@ -3962,6 +3965,7 @@
"Squash %lld commits into 1. Later commits in the affected history will be replayed." = "Squash %lld commits into 1. Later commits in the affected history will be replayed.";
"Squash Commits" = "Squash Commits";
"Squash Commits…" = "Squash Commits…";
"Squash and Fixup need a preceding commit that is not dropped." = "Squash and Fixup need a preceding commit that is not dropped.";
"Squash and merge" = "Squash and merge";
"Squash → 1" = "Squash → 1";
"Stable (Recommended)" = "Stable (Recommended)";
Expand Down Expand Up @@ -4267,6 +4271,7 @@
"The clipboard image could not be read" = "The clipboard image could not be read";
"The clipboard image is empty" = "The clipboard image is empty";
"The clipboard image is larger than 100 MB" = "The clipboard image is larger than 100 MB";
"The commit messages in this plan are not valid." = "The commit messages in this plan are not valid.";
"The configuration changed on disk. Refresh and try again." = "The configuration changed on disk. Refresh and try again.";
"The configuration could not be read. Check the selected directory and file permissions." = "The configuration could not be read. Check the selected directory and file permissions.";
"The configured build tool is not executable: %@" = "The configured build tool is not executable: %@";
Expand Down Expand Up @@ -5214,6 +5219,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$@";
Expand All @@ -5228,6 +5234,10 @@
"git.log.branchNameEmpty" = "Specify name for the new branch";
"git.log.branchNameHead" = "HEAD is a reserved keyword";
"git.log.olderCommitsFailed" = "Could not load older commits. Refresh history";
"git.rebasePlan.issue.emptyReword" = "Every Reword step needs a commit message.";
"git.rebasePlan.issue.emptySquash" = "Enter a Squash message or use the default combined messages.";
"git.rebasePlan.issue.invalidMessage" = "The commit messages in this plan are not valid.";
"git.rebasePlan.issue.missingPreceding" = "Squash and Fixup need a preceding commit that is not dropped.";
"git.rollback.confirm" = "Rollback";
"git.rollback.deleteAdded" = "Delete local copies of added files";
"git.rollback.include" = "Include in rollback";
Expand Down
10 changes: 10 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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." = "无法读取项目索引设置,请重新打开项目后重试。";
Expand Down Expand Up @@ -1491,6 +1492,7 @@
"Enter a ChangeList name (1–100 characters)." = "请输入更改列表名称(1–100 个字符)。";
"Enter a JSON object of adapter arguments no larger than 256 KiB." = "请输入不超过 256 KiB 的适配器参数 JSON 对象。";
"Enter a JVM debug port between 1 and 65535." = "请输入 1 到 65535 之间的 JVM 调试端口。";
"Enter a Squash message or use the default combined messages." = "请输入 Squash 信息,或使用默认的合并信息。";
"Enter a branch name" = "请输入分支名称";
"Enter a branch name before publishing." = "请输入分支名称后再发布。";
"Enter a branch name." = "请输入分支名称。";
Expand Down Expand Up @@ -1541,6 +1543,7 @@
"Evaluating…" = "正在计算…";
"Event" = "事件";
"Events over time" = "事件分布";
"Every Reword step needs a commit message." = "每个 Reword 步骤都需要提交信息。";
"Every saved version of this project's files is deleted. Open files are not changed." = "此项目文件的所有已保存版本都会被删除,已打开的文件不受影响。";
"Example" = "示例";
"Exception" = "异常";
Expand Down Expand Up @@ -3962,6 +3965,7 @@
"Squash %lld commits into 1. Later commits in the affected history will be replayed." = "将 %lld 笔提交合并为 1 笔,并重放其后的受影响提交。";
"Squash Commits" = "合并所选提交";
"Squash Commits…" = "合并所选提交…";
"Squash and Fixup need a preceding commit that is not dropped." = "Squash 和 Fixup 需要前面有一个未被丢弃的提交。";
"Squash and merge" = "压缩并合并";
"Squash → 1" = "合并为 1 笔";
"Stable (Recommended)" = "正式版(推荐)";
Expand Down Expand Up @@ -4267,6 +4271,7 @@
"The clipboard image could not be read" = "无法读取剪贴板中的图片";
"The clipboard image is empty" = "剪贴板中的图片为空";
"The clipboard image is larger than 100 MB" = "剪贴板中的图片超过 100 MB";
"The commit messages in this plan are not valid." = "此计划中的提交信息无效。";
"The configuration changed on disk. Refresh and try again." = "磁盘上的配置已变化。请刷新后重试。";
"The configuration could not be read. Check the selected directory and file permissions." = "无法读取配置,请检查所选目录和文件权限。";
"The configured build tool is not executable: %@" = "配置的构建工具无法执行:%@";
Expand Down Expand Up @@ -5214,6 +5219,7 @@
"from JAVA_HOME" = "来自 JAVA_HOME";
"generated main" = "生成的主代码";
"generated test" = "生成的测试代码";
"git.changeTargets.prepareFailed" = "无法准备所选更改。";
"git.changelists.invalidName" = "请输入不重名、非空且不超过 100 个字符的名称。";
"git.diff.repositoryPreviewEmptyTitle" = "仓库差异";
"git.diff.repositoryPreviewTitle" = "仓库差异: %1$@";
Expand All @@ -5228,6 +5234,10 @@
"git.log.branchNameEmpty" = "请指定新分支的名称";
"git.log.branchNameHead" = "HEAD 是保留关键字";
"git.log.olderCommitsFailed" = "无法加载更早的提交,点击刷新历史";
"git.rebasePlan.issue.emptyReword" = "每个 Reword 步骤都需要提交信息。";
"git.rebasePlan.issue.emptySquash" = "请输入 Squash 信息,或使用默认的合并信息。";
"git.rebasePlan.issue.invalidMessage" = "此计划中的提交信息无效。";
"git.rebasePlan.issue.missingPreceding" = "Squash 和 Fixup 需要前面有一个未被丢弃的提交。";
"git.rollback.confirm" = "回滚";
"git.rollback.deleteAdded" = "删除新增文件的本地副本";
"git.rollback.include" = "纳入回滚";
Expand Down
8 changes: 8 additions & 0 deletions macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,14 @@ struct RustGitOperations: GitOperations, Sendable {
core.cancel(operationID: operationID)
}

func corePlan<Request: Encodable & Sendable, Response: Decodable & Sendable>(
_ command: GitCorePlanCommand,
_ request: Request
) -> Response? {
let result: Result<Response, RustCoreBridge.CoreCallError> = 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)
Expand Down
87 changes: 28 additions & 59 deletions macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ enum GitReferenceRowsBuilder {
collapsedGroups: Set<String>
) -> [GitReferenceRow] {
var rows: [GitReferenceRow] = []
guard let nodes = GitReferenceTreeNode.plan(for: references) else { return rows }
append(
GitReferenceTreeNode.build(from: references),
nodes,
references: references,
kind: kind,
depth: 0,
collapsedGroups: collapsedGroups,
Expand All @@ -69,6 +71,7 @@ enum GitReferenceRowsBuilder {

private static func append(
_ nodes: [GitReferenceTreeNode],
references: [GitReference],
kind: GitReferenceKind,
depth: Int,
collapsedGroups: Set<String>,
Expand All @@ -78,7 +81,8 @@ enum GitReferenceRowsBuilder {
// A node can be both: `feature` may be a branch and also the prefix
// of `feature/x`, in which case it emits a reference row and a group
// row at the same depth.
if let reference = node.reference {
if let index = node.reference, references.indices.contains(index) {
let reference = references[index]
rows.append(
GitReferenceRow(
id: "reference:" + node.path,
Expand All @@ -104,6 +108,7 @@ enum GitReferenceRowsBuilder {
guard !isCollapsed else { continue }
append(
node.children,
references: references,
kind: kind,
depth: depth + 1,
collapsedGroups: collapsedGroups,
Expand Down Expand Up @@ -250,73 +255,37 @@ enum GitReferenceRowMenu {
}
}

/// Intermediate tree used only to group references by their `/`-separated path
/// before flattening.
struct GitReferenceTreeNode: Identifiable {
/// One grouped path segment planned by Core (`git.referenceTree`).
///
/// Core owns the `/` grouping and the sibling order; the view keeps expansion,
/// indentation, and row identities.
struct GitReferenceTreeNode: Identifiable, Decodable {
let path: String
let name: String
let reference: GitReference?
/// Index into the references sent to Core; `nil` for a pure group.
let reference: Int?
let children: [GitReferenceTreeNode]

var id: String { path }

static func build(from references: [GitReference]) -> [GitReferenceTreeNode] {
let root = MutableGitReferenceTreeNode(name: "", path: "")

for reference in references {
let components = reference.shortName
.split(separator: "/")
.map(String.init)
guard !components.isEmpty else { continue }

var node = root
var pathComponents: [String] = []
for component in components {
pathComponents.append(component)
if node.children[component] == nil {
node.children[component] = MutableGitReferenceTreeNode(
name: component,
path: pathComponents.joined(separator: "/")
)
}
node = node.children[component]!
}
node.reference = reference
}

return makeNodes(from: root)
private struct Request: Encodable {
struct Reference: Encodable { let name: String }
let references: [Reference]
}

private static func makeNodes(from node: MutableGitReferenceTreeNode) -> [GitReferenceTreeNode] {
node.children.values
.map { child in
GitReferenceTreeNode(
path: child.path,
name: child.name,
reference: child.reference,
children: makeNodes(from: child)
)
}
// Leaves before folders, then natural order, so the list is stable
// across rebuilds of the unordered child dictionary.
.sorted { lhs, rhs in
if (lhs.reference != nil) != (rhs.reference != nil) {
return lhs.reference != nil
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
private struct Plan: Decodable {
let nodes: [GitReferenceTreeNode]
}
}

private final class MutableGitReferenceTreeNode {
let name: String
let path: String
var reference: GitReference?
var children: [String: MutableGitReferenceTreeNode] = [:]

init(name: String, path: String) {
self.name = name
self.path = path
/// Plans the tree of one kind of references. `nil` means Core rejected the
/// request, in which case the pane shows no rows rather than a guessed order.
static func plan(for references: [GitReference]) -> [GitReferenceTreeNode]? {
guard !references.isEmpty else { return [] }
let result: Result<Plan, RustCoreBridge.CoreCallError> = RustCoreBridge().executeResult(
command: "git.referenceTree",
payload: Request(references: references.map { Request.Reference(name: $0.shortName) })
)
return try? result.get().nodes
}
}

Expand Down
33 changes: 18 additions & 15 deletions macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading