diff --git a/CLI/cmux_open.swift b/CLI/cmux_open.swift index 15d644474e..449070a40e 100644 --- a/CLI/cmux_open.swift +++ b/CLI/cmux_open.swift @@ -192,6 +192,7 @@ extension CMUXCLI { var input: DiffInput var allowedFiles: [DiffViewerAllowedFile] var deferredSourceSet: DiffViewerDeferredSourceSet? = nil + var completeDeferred: (() throws -> DiffViewerWriteResult)? = nil } private struct DiffViewerDeferredSourceSet { @@ -203,11 +204,30 @@ extension CMUXCLI { private struct DiffViewerDeferredSourcePage { var source: DiffSource var url: URL + var viewerURL: URL var titleOverride: String? var context: DiffSourceContext var sourceOptions: [DiffViewerSourceOption] var repoOptions: [DiffViewerSourceOption] var baseOptions: [DiffViewerSourceOption] + var allowsSourceFallback: Bool = false + var sourceFallbacks: [DiffSource: DiffViewerDeferredSourceFallback] = [:] + } + + private struct DiffViewerDeferredSourceFallback { + var url: URL + var viewerURL: URL + var context: DiffSourceContext + var sourceOptions: [DiffViewerSourceOption] + var repoOptions: [DiffViewerSourceOption] + var baseOptions: [DiffViewerSourceOption] + } + + private struct DiffViewerDeferredCompletion { + var input: DiffInput + var fileURL: URL + var viewerURL: URL + var completedPageURLs: Set } private struct DiffViewerRepoOption { @@ -220,6 +240,12 @@ extension CMUXCLI { var label: String } + private struct DiffViewerGitHTMLSetTarget { + var directory: URL + var mapper: DiffViewerURLMapper + var groupID: String + } + private struct DiffViewerSourceOption { var value: String var label: String @@ -414,6 +440,78 @@ extension CMUXCLI { } } + private enum DiffViewerShortcutAction: String, CaseIterable { + case scrollDown = "diffViewerScrollDown" + case scrollUp = "diffViewerScrollUp" + case scrollToBottom = "diffViewerScrollToBottom" + case scrollToTop = "diffViewerScrollToTop" + case openFileSearch = "diffViewerOpenFileSearch" + + var defaultShortcut: DiffViewerShortcut { + switch self { + case .scrollDown: + return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "j")) + case .scrollUp: + return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "k")) + case .scrollToBottom: + return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "g", shift: true)) + case .scrollToTop: + return DiffViewerShortcut( + first: DiffViewerShortcutStroke(key: "g"), + second: DiffViewerShortcutStroke(key: "g") + ) + case .openFileSearch: + return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "/")) + } + } + } + + private struct DiffViewerShortcutStroke: Equatable { + var key: String + var command: Bool + var shift: Bool + var option: Bool + var control: Bool + + init(key: String, command: Bool = false, shift: Bool = false, option: Bool = false, control: Bool = false) { + self.key = key + self.command = command + self.shift = shift + self.option = option + self.control = control + } + + var jsonObject: [String: Any] { + [ + "key": key, + "command": command, + "shift": shift, + "option": option, + "control": control, + ] + } + } + + private struct DiffViewerShortcut: Equatable { + var first: DiffViewerShortcutStroke? + var second: DiffViewerShortcutStroke? + + static let unbound = DiffViewerShortcut(first: nil, second: nil) + + var isUnbound: Bool { first == nil } + + var jsonObject: [String: Any] { + if isUnbound { + return ["unbound": true] + } + var object: [String: Any] = ["first": first?.jsonObject ?? [:]] + if let second { + object["second"] = second.jsonObject + } + return object + } + } + private enum DiffSource: CaseIterable, Equatable { case unstaged case staged @@ -774,20 +872,20 @@ extension CMUXCLI { let payload = try activeClient.sendV2(method: "browser.open_split", params: params) if jsonOutput { + let completedViewer = try completeDeferredDiffViewer(viewer) var response = payload - response["path"] = viewer.fileURL.path - response["url"] = viewer.url.absoluteString - response["title"] = viewer.title - response["source"] = viewer.input.sourceLabel + response["path"] = completedViewer.fileURL.path + response["url"] = completedViewer.url.absoluteString + response["title"] = completedViewer.title + response["source"] = completedViewer.input.sourceLabel print(jsonString(formatIDs(response, mode: idFormat))) - completeDeferredDiffViewerSources(viewer.deferredSourceSet) return } + let completedViewer = try completeDeferredDiffViewer(viewer) let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown" let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown" - print("OK surface=\(surfaceText) pane=\(paneText) path=\(viewer.fileURL.path)") - completeDeferredDiffViewerSources(viewer.deferredSourceSet) + print("OK surface=\(surfaceText) pane=\(paneText) path=\(completedViewer.fileURL.path)") } private func canonicalDiffSourceContext( @@ -2860,6 +2958,28 @@ extension CMUXCLI { appearance: DiffViewerAppearance, context: DiffSourceContext ) throws -> DiffViewerWriteResult { + let target = try makeDiffViewerGitHTMLSetTarget() + if selectedSource != .lastTurn { + return try writeOpeningGitDiffViewerHTMLSet( + selectedSource: selectedSource, + titleOverride: titleOverride, + layout: layout, + appearance: appearance, + context: context, + target: target + ) + } + return try writeCompleteGitDiffViewerHTMLSet( + selectedSource: selectedSource, + titleOverride: titleOverride, + layout: layout, + appearance: appearance, + context: context, + target: target + ) + } + + private func makeDiffViewerGitHTMLSetTarget() throws -> DiffViewerGitHTMLSetTarget { let directory = try diffViewerDirectory() let origin = try diffViewerHTTPServerOrigin(rootDirectory: directory) let mapper = DiffViewerURLMapper( @@ -2869,10 +2989,161 @@ extension CMUXCLI { ) let timestamp = Int(Date().timeIntervalSince1970) let groupID = "\(timestamp)-\(UUID().uuidString.prefix(8))" + return DiffViewerGitHTMLSetTarget(directory: directory, mapper: mapper, groupID: groupID) + } + + private func diffViewerLoadingDiffMessage(_ target: String) -> String { + let format = CMUXDiffViewerLocalization.string( + "diffViewer.loadingDiffTarget", + defaultValue: "Loading diff: %@" + ) + return String(format: format, locale: Locale.current, target) + } + + private func writeOpeningGitDiffViewerHTMLSet( + selectedSource: DiffSource, + titleOverride: String?, + layout: String, + appearance: DiffViewerAppearance, + context: DiffSourceContext, + target: DiffViewerGitHTMLSetTarget + ) throws -> DiffViewerWriteResult { + let directory = target.directory + let mapper = target.mapper + let groupID = target.groupID + let repoRoot = try gitRepoRootForDiff(context) + let openingFileURL = directory.appendingPathComponent( + "diff-\(groupID)-opening.html", + isDirectory: false + ) + let openingURL = try mapper.viewerURL(for: openingFileURL) + let sourceLabel = "git \(selectedSource.slug)" + let title = titleOverride ?? selectedSource.title + let message = diffViewerLoadingDiffMessage(selectedSource.menuLabel) + try writeDiffViewerStatusHTML( + to: openingFileURL, + title: title, + sourceLabel: sourceLabel, + message: message, + isError: false, + pollForReplacement: true, + layout: layout, + appearance: appearance, + sourceOptions: [], + repoOptions: [], + baseOptions: [], + repoRoot: repoRoot, + branchBaseRef: selectedSource == .branch ? context.branchBaseRef : nil + ) + let assets = try ensureDiffViewerAssets(nextTo: openingFileURL) + let allowedFiles = try diffViewerAllowedFiles( + pageURLs: [openingFileURL], + assets: assets, + mapper: mapper + ) + try writeDiffViewerHTTPManifest( + token: mapper.token, + files: allowedFiles, + rootDirectory: directory + ) + + let responseInput = DiffInput( + patch: "", + sourceLabel: sourceLabel, + defaultTitle: selectedSource.title, + emptyMessage: selectedSource.emptyMessage, + externalURL: nil + ) + return DiffViewerWriteResult( + fileURL: openingFileURL, + url: openingURL, + title: title, + input: responseInput, + allowedFiles: allowedFiles, + completeDeferred: { [self] in + do { + let completed = try writeCompleteGitDiffViewerHTMLSet( + selectedSource: selectedSource, + titleOverride: titleOverride, + layout: layout, + appearance: appearance, + context: context, + target: target, + extraAllowedPageURL: openingFileURL + ) + var finalized = completed + + var completedPageURLs = Set() + do { + if let selectedCompletion = try completeDeferredDiffViewerSelectedSource( + completed.deferredSourceSet, + selectedURL: completed.fileURL + ) { + completedPageURLs.formUnion(selectedCompletion.completedPageURLs) + finalized.fileURL = selectedCompletion.fileURL + finalized.url = selectedCompletion.viewerURL + finalized.input = selectedCompletion.input + finalized.title = titleOverride ?? selectedCompletion.input.defaultTitle + } + } catch { + try? writeDiffViewerRedirectHTML( + to: openingFileURL, + title: title, + targetURL: completed.url + ) + throw error + } + try writeDiffViewerRedirectHTML( + to: openingFileURL, + title: finalized.title, + targetURL: finalized.url + ) + _ = try completeDeferredDiffViewerSources( + completed.deferredSourceSet, + selectedURL: completed.fileURL, + completedPageURLs: completedPageURLs + ) + return finalized + } catch { + let message = diffViewerErrorMessage(error) + try? writeDiffViewerStatusHTML( + to: openingFileURL, + title: title, + sourceLabel: sourceLabel, + message: message, + isError: true, + pollForReplacement: false, + layout: layout, + appearance: appearance, + sourceOptions: [], + repoOptions: [], + baseOptions: [], + repoRoot: repoRoot, + branchBaseRef: selectedSource == .branch ? context.branchBaseRef : nil + ) + throw error + } + } + ) + } + + private func writeCompleteGitDiffViewerHTMLSet( + selectedSource: DiffSource, + titleOverride: String?, + layout: String, + appearance: DiffViewerAppearance, + context: DiffSourceContext, + target: DiffViewerGitHTMLSetTarget, + extraAllowedPageURL: URL? = nil + ) throws -> DiffViewerWriteResult { + let directory = target.directory + let mapper = target.mapper + let groupID = target.groupID let requestedSource = selectedSource let repoRoot = try gitRepoRootForDiff(context) let explicitBranchBaseRef = normalizedDiffSourceValue(context.branchBaseRef) var selectedSource = requestedSource + let shouldDeferSelectedSource = requestedSource != .lastTurn func sourceContext(for source: DiffSource, repoRoot: String) throws -> DiffSourceContext { var sourceContext = context sourceContext.repoRoot = repoRoot @@ -2887,26 +3158,28 @@ extension CMUXCLI { return sourceContext } var selectedContext = try sourceContext(for: selectedSource, repoRoot: repoRoot) - let selectedInput: DiffInput - do { - selectedInput = try nonEmptyGitDiffInput(source: selectedSource, context: selectedContext) - } catch let error as EmptyDiffSourceError { - guard selectedSource != .lastTurn else { - throw CLIError(message: error.message) - } - var fallback: (source: DiffSource, context: DiffSourceContext, input: DiffInput)? - for candidate in DiffSource.allCases where candidate != selectedSource { - guard let candidateContext = try? sourceContext(for: candidate, repoRoot: repoRoot), - let candidateInput = try? nonEmptyGitDiffInput(source: candidate, context: candidateContext) else { - continue + var selectedInput: DiffInput? + if !shouldDeferSelectedSource { + do { + selectedInput = try nonEmptyGitDiffInput(source: selectedSource, context: selectedContext) + } catch let error as EmptyDiffSourceError { + guard selectedSource != .lastTurn else { + throw CLIError(message: error.message) } - fallback = (candidate, candidateContext, candidateInput) - break + var fallback: (source: DiffSource, context: DiffSourceContext, input: DiffInput)? + for candidate in DiffSource.allCases where candidate != selectedSource { + guard let candidateContext = try? sourceContext(for: candidate, repoRoot: repoRoot), + let candidateInput = try? nonEmptyGitDiffInput(source: candidate, context: candidateContext) else { + continue + } + fallback = (candidate, candidateContext, candidateInput) + break + } + guard let fallback else { throw CLIError(message: error.message) } + selectedSource = fallback.source + selectedContext = fallback.context + selectedInput = fallback.input } - guard let fallback else { throw CLIError(message: error.message) } - selectedSource = fallback.source - selectedContext = fallback.context - selectedInput = fallback.input } let fileURLs = Dictionary(uniqueKeysWithValues: DiffSource.allCases.map { source in ( @@ -2999,24 +3272,90 @@ extension CMUXCLI { ) var deferredPages: [DiffViewerDeferredSourcePage] = [] + if shouldDeferSelectedSource { + try writeDiffViewerStatusHTML( + to: selectedFileURL, + title: titleOverride ?? selectedSource.title, + sourceLabel: "git \(selectedSource.slug)", + message: diffViewerLoadingDiffMessage(selectedSource.menuLabel), + isError: false, + pollForReplacement: true, + layout: layout, + appearance: appearance, + sourceOptions: sourceOptions, + repoOptions: selectedRepoOptions, + baseOptions: selectedSource == .branch ? baseOptions : [], + repoRoot: repoRoot, + branchBaseRef: selectedSource == .branch ? selectedContext.branchBaseRef : nil + ) + let sourceFallbacks = Dictionary(uniqueKeysWithValues: DiffSource.allCases.compactMap { source -> (DiffSource, DiffViewerDeferredSourceFallback)? in + guard source != selectedSource, + let fallbackContext = try? sourceContext(for: source, repoRoot: repoRoot), + let fallbackFileURL = fileURLs[source], + let fallbackViewerURL = urls[source] else { + return nil + } + return ( + source, + DiffViewerDeferredSourceFallback( + url: fallbackFileURL, + viewerURL: fallbackViewerURL, + context: fallbackContext, + sourceOptions: diffViewerSourceOptions(selected: source, urls: urls), + repoOptions: repoOptionsForSource(source, selectedRepoRoot: repoRoot), + baseOptions: source == .branch ? baseOptions : [] + ) + ) + }) + deferredPages.append( + DiffViewerDeferredSourcePage( + source: selectedSource, + url: selectedFileURL, + viewerURL: selectedURL, + titleOverride: titleOverride, + context: selectedContext, + sourceOptions: sourceOptions, + repoOptions: selectedRepoOptions, + baseOptions: selectedSource == .branch ? baseOptions : [], + allowsSourceFallback: true, + sourceFallbacks: sourceFallbacks + ) + ) + } for source in DiffSource.allCases where source != selectedSource { if let url = fileURLs[source] { - try writePendingDiffViewerHTML( - to: url, - title: source.title, - message: "\(CMUXDiffViewerLocalization.string("diffViewer.loadingDiff", defaultValue: "Loading diff...")) \(source.menuLabel)", - pollForReplacement: true - ) var pageContext = selectedContext if source == .branch { pageContext.branchBaseRef = branchBaseForOptions } else { pageContext.branchBaseRef = nil } + let viewerURL: URL + if let sourceURL = urls[source] { + viewerURL = sourceURL + } else { + viewerURL = try mapper.viewerURL(for: url) + } + try writeDiffViewerStatusHTML( + to: url, + title: source.title, + sourceLabel: "git \(source.slug)", + message: diffViewerLoadingDiffMessage(source.menuLabel), + isError: false, + pollForReplacement: true, + layout: layout, + appearance: appearance, + sourceOptions: diffViewerSourceOptions(selected: source, urls: urls), + repoOptions: repoOptionsForSource(source, selectedRepoRoot: repoRoot), + baseOptions: source == .branch ? baseOptions : [], + repoRoot: repoRoot, + branchBaseRef: source == .branch ? pageContext.branchBaseRef : nil + ) deferredPages.append( DiffViewerDeferredSourcePage( source: source, url: url, + viewerURL: viewerURL, titleOverride: nil, context: pageContext, sourceOptions: diffViewerSourceOptions(selected: source, urls: urls), @@ -3030,23 +3369,40 @@ extension CMUXCLI { for source in DiffSource.allCases { for option in repoCandidates where option.repoRoot != repoRoot { guard let url = repoFileURLsBySource[source]?[option.repoRoot] else { continue } - try writePendingDiffViewerHTML( + let viewerURL: URL + if let repoURL = repoURLsBySource[source]?[option.repoRoot] { + viewerURL = repoURL + } else { + viewerURL = try mapper.viewerURL(for: url) + } + let pageContext = DiffSourceContext( + workspaceId: selectedContext.workspaceId, + surfaceId: selectedContext.surfaceId, + repoRoot: option.repoRoot, + branchBaseRef: source == .branch ? explicitBranchBaseRef : selectedContext.branchBaseRef + ) + try writeDiffViewerStatusHTML( to: url, title: option.label, - message: "\(CMUXDiffViewerLocalization.string("diffViewer.loadingDiff", defaultValue: "Loading diff...")) \(option.label)", - pollForReplacement: true + sourceLabel: "git \(source.slug)", + message: diffViewerLoadingDiffMessage(option.label), + isError: false, + pollForReplacement: true, + layout: layout, + appearance: appearance, + sourceOptions: sourceOptionsForRepo(selected: source, selectedRepoRoot: option.repoRoot), + repoOptions: repoOptionsForSource(source, selectedRepoRoot: option.repoRoot), + baseOptions: [], + repoRoot: option.repoRoot, + branchBaseRef: source == .branch ? explicitBranchBaseRef : nil ) deferredPages.append( DiffViewerDeferredSourcePage( source: source, url: url, + viewerURL: viewerURL, titleOverride: source == selectedSource ? titleOverride : nil, - context: DiffSourceContext( - workspaceId: selectedContext.workspaceId, - surfaceId: selectedContext.surfaceId, - repoRoot: option.repoRoot, - branchBaseRef: source == .branch ? explicitBranchBaseRef : selectedContext.branchBaseRef - ), + context: pageContext, sourceOptions: sourceOptionsForRepo(selected: source, selectedRepoRoot: option.repoRoot), repoOptions: repoOptionsForSource(source, selectedRepoRoot: option.repoRoot), baseOptions: [] @@ -3057,18 +3413,38 @@ extension CMUXCLI { for option in baseCandidates where !(branchBaseForOptions.map { $0 == option.ref } ?? false) { guard let url = baseFileURLs[option.ref] else { continue } - try writePendingDiffViewerHTML( + let viewerURL: URL + if let baseURL = baseURLs[option.ref] { + viewerURL = baseURL + } else { + viewerURL = try mapper.viewerURL(for: url) + } + var pageContext = selectedContext + pageContext.branchBaseRef = option.ref + try writeDiffViewerStatusHTML( to: url, title: option.label, - message: "\(CMUXDiffViewerLocalization.string("diffViewer.loadingDiff", defaultValue: "Loading diff...")) \(option.label)", - pollForReplacement: true + sourceLabel: "git \(DiffSource.branch.slug)", + message: diffViewerLoadingDiffMessage(option.label), + isError: false, + pollForReplacement: true, + layout: layout, + appearance: appearance, + sourceOptions: diffViewerSourceOptions(selected: .branch, urls: urls), + repoOptions: repoOptionsForSource(.branch, selectedRepoRoot: repoRoot), + baseOptions: diffViewerBranchBaseOptions( + selectedBaseRef: option.ref, + candidates: baseCandidates, + urls: baseURLs + ), + repoRoot: repoRoot, + branchBaseRef: option.ref ) - var pageContext = selectedContext - pageContext.branchBaseRef = option.ref deferredPages.append( DiffViewerDeferredSourcePage( source: .branch, url: url, + viewerURL: viewerURL, titleOverride: selectedSource == .branch ? titleOverride : nil, context: pageContext, sourceOptions: diffViewerSourceOptions(selected: .branch, urls: urls), @@ -3082,39 +3458,56 @@ extension CMUXCLI { ) } - try writeDiffViewerHTML( - to: selectedFileURL, - patch: selectedInput.patch, - title: titleOverride ?? selectedInput.defaultTitle, - sourceLabel: selectedInput.sourceLabel, - externalURL: selectedInput.externalURL, - remotePatchURL: selectedInput.remotePatchURL, - layout: layout, - appearance: appearance, - sourceOptions: sourceOptions, - repoOptions: selectedRepoOptions, - baseOptions: selectedSource == .branch ? baseOptions : [], - repoRoot: repoRoot, - branchBaseRef: selectedSource == .branch ? selectedContext.branchBaseRef : nil - ) + if let selectedInput { + try writeDiffViewerHTML( + to: selectedFileURL, + patch: selectedInput.patch, + title: titleOverride ?? selectedInput.defaultTitle, + sourceLabel: selectedInput.sourceLabel, + externalURL: selectedInput.externalURL, + remotePatchURL: selectedInput.remotePatchURL, + layout: layout, + appearance: appearance, + sourceOptions: sourceOptions, + repoOptions: selectedRepoOptions, + baseOptions: selectedSource == .branch ? baseOptions : [], + repoRoot: repoRoot, + branchBaseRef: selectedSource == .branch ? selectedContext.branchBaseRef : nil + ) + } let assets = try ensureDiffViewerAssets(nextTo: selectedFileURL) let pageURLs = [selectedFileURL] + deferredPages.map(\.url) - let allowedFiles = try diffViewerAllowedFiles( + var allowedFiles = try diffViewerAllowedFiles( pageURLs: pageURLs, assets: assets, mapper: mapper ) + if let extraAllowedPageURL { + allowedFiles = try diffViewerAllowedFilesWithExtraPage( + extraAllowedPageURL, + files: allowedFiles, + mapper: mapper + ) + } try writeDiffViewerHTTPManifest( token: mapper.token, files: allowedFiles, rootDirectory: directory ) + let responseInput = selectedInput ?? DiffInput( + patch: "", + sourceLabel: "git \(selectedSource.slug)", + defaultTitle: selectedSource.title, + emptyMessage: selectedSource.emptyMessage, + externalURL: nil + ) + return DiffViewerWriteResult( fileURL: selectedFileURL, url: selectedURL, - title: titleOverride ?? selectedInput.defaultTitle, - input: selectedInput, + title: titleOverride ?? responseInput.defaultTitle, + input: responseInput, allowedFiles: allowedFiles, deferredSourceSet: DiffViewerDeferredSourceSet( pages: deferredPages, @@ -3124,39 +3517,199 @@ extension CMUXCLI { ) } - private func completeDeferredDiffViewerSources(_ sourceSet: DiffViewerDeferredSourceSet?) { - guard let sourceSet else { return } + private func completeDeferredDiffViewer(_ viewer: DiffViewerWriteResult) throws -> DiffViewerWriteResult { + do { + if let completeDeferred = viewer.completeDeferred { + return try completeDeferred() + } + let selectedCompletion = try completeDeferredDiffViewerSources( + viewer.deferredSourceSet, + selectedURL: viewer.fileURL + ) + guard let selectedCompletion else { return viewer } + var finalized = viewer + finalized.fileURL = selectedCompletion.fileURL + finalized.url = selectedCompletion.viewerURL + finalized.input = selectedCompletion.input + finalized.title = selectedCompletion.input.defaultTitle + return finalized + } catch { + throw diffViewerCommandError(error) + } + } + + private func completeDeferredDiffViewerSelectedSource( + _ sourceSet: DiffViewerDeferredSourceSet?, + selectedURL: URL + ) throws -> DiffViewerDeferredCompletion? { + guard let sourceSet else { return nil } + guard let page = sourceSet.pages.first(where: { $0.url == selectedURL }) else { + return nil + } + do { + return try completeDeferredDiffViewerSource(page, sourceSet: sourceSet) + } catch { + writeDeferredDiffViewerError(error, page: page, sourceSet: sourceSet) + throw error + } + } + + private func completeDeferredDiffViewerSources( + _ sourceSet: DiffViewerDeferredSourceSet?, + selectedURL: URL? = nil, + completedPageURLs initialCompletedPageURLs: Set = [] + ) throws -> DiffViewerDeferredCompletion? { + guard let sourceSet else { return nil } + var completedPageURLs = initialCompletedPageURLs + var selectedCompletion: DiffViewerDeferredCompletion? + var selectedError: Error? for page in sourceSet.pages { + guard !completedPageURLs.contains(page.url) else { continue } do { - var pageContext = page.context - if page.source == .branch { - let repoRoot = try gitRepoRootForDiff(pageContext) - pageContext.repoRoot = repoRoot - pageContext.branchBaseRef = try resolvedGitBranchDiffBaseRef(pageContext.branchBaseRef, in: repoRoot) - } - let input = try nonEmptyGitDiffInput(source: page.source, context: pageContext) - try writeDiffViewerHTML( - to: page.url, - patch: input.patch, - title: page.titleOverride ?? input.defaultTitle, - sourceLabel: input.sourceLabel, - externalURL: input.externalURL, - remotePatchURL: input.remotePatchURL, - layout: sourceSet.layout, - appearance: sourceSet.appearance, - sourceOptions: page.sourceOptions, - repoOptions: page.repoOptions, - baseOptions: page.baseOptions, - repoRoot: pageContext.repoRoot, - branchBaseRef: page.source == .branch ? pageContext.branchBaseRef : nil - ) + let completion = try completeDeferredDiffViewerSource(page, sourceSet: sourceSet) + completedPageURLs.formUnion(completion.completedPageURLs) + if page.url == selectedURL { + selectedCompletion = completion + } } catch { - let message = diffViewerErrorMessage(error) - try? writePendingDiffViewerHTML(to: page.url, title: page.source.title, message: message, pollForReplacement: false) + writeDeferredDiffViewerError(error, page: page, sourceSet: sourceSet) + if page.url == selectedURL { + selectedError = error + } + } + } + if let selectedError { + throw selectedError + } + return selectedCompletion + } + + private func writeDeferredDiffViewerError( + _ error: Error, + page: DiffViewerDeferredSourcePage, + sourceSet: DiffViewerDeferredSourceSet + ) { + let message = diffViewerErrorMessage(error) + try? writeDiffViewerStatusHTML( + to: page.url, + title: page.titleOverride ?? page.source.title, + sourceLabel: "git \(page.source.slug)", + message: message, + isError: true, + pollForReplacement: false, + layout: sourceSet.layout, + appearance: sourceSet.appearance, + sourceOptions: page.sourceOptions, + repoOptions: page.repoOptions, + baseOptions: page.baseOptions, + repoRoot: page.context.repoRoot, + branchBaseRef: page.source == .branch ? page.context.branchBaseRef : nil + ) + } + + private func completeDeferredDiffViewerSource( + _ page: DiffViewerDeferredSourcePage, + sourceSet: DiffViewerDeferredSourceSet + ) throws -> DiffViewerDeferredCompletion { + do { + return try writeDeferredDiffViewerSource( + page: page, + source: page.source, + context: page.context, + sourceOptions: page.sourceOptions, + repoOptions: page.repoOptions, + baseOptions: page.baseOptions, + sourceSet: sourceSet + ) + } catch let error as EmptyDiffSourceError where page.allowsSourceFallback { + for source in DiffSource.allCases where source != page.source { + guard let fallback = page.sourceFallbacks[source] else { continue } + do { + let fallbackPage = DiffViewerDeferredSourcePage( + source: source, + url: fallback.url, + viewerURL: fallback.viewerURL, + titleOverride: page.titleOverride, + context: fallback.context, + sourceOptions: fallback.sourceOptions, + repoOptions: fallback.repoOptions, + baseOptions: fallback.baseOptions + ) + var completion = try writeDeferredDiffViewerSource( + page: fallbackPage, + source: source, + context: fallback.context, + sourceOptions: fallback.sourceOptions, + repoOptions: fallback.repoOptions, + baseOptions: fallback.baseOptions, + sourceSet: sourceSet + ) + try? writeDiffViewerStatusHTML( + to: page.url, + title: page.titleOverride ?? page.source.title, + sourceLabel: "git \(page.source.slug)", + message: error.message, + isError: true, + pollForReplacement: false, + layout: sourceSet.layout, + appearance: sourceSet.appearance, + sourceOptions: page.sourceOptions, + repoOptions: page.repoOptions, + baseOptions: page.baseOptions, + repoRoot: page.context.repoRoot, + branchBaseRef: page.source == .branch ? page.context.branchBaseRef : nil + ) + completion.completedPageURLs.insert(page.url) + return completion + } catch is EmptyDiffSourceError { + continue + } catch let fallbackError { + throw fallbackError + } } + throw error } } + private func writeDeferredDiffViewerSource( + page: DiffViewerDeferredSourcePage, + source: DiffSource, + context: DiffSourceContext, + sourceOptions: [DiffViewerSourceOption], + repoOptions: [DiffViewerSourceOption], + baseOptions: [DiffViewerSourceOption], + sourceSet: DiffViewerDeferredSourceSet + ) throws -> DiffViewerDeferredCompletion { + var pageContext = context + if source == .branch { + let repoRoot = try gitRepoRootForDiff(pageContext) + pageContext.repoRoot = repoRoot + pageContext.branchBaseRef = try resolvedGitBranchDiffBaseRef(pageContext.branchBaseRef, in: repoRoot) + } + let input = try nonEmptyGitDiffInput(source: source, context: pageContext) + try writeDiffViewerHTML( + to: page.url, + patch: input.patch, + title: page.titleOverride ?? input.defaultTitle, + sourceLabel: input.sourceLabel, + externalURL: input.externalURL, + remotePatchURL: input.remotePatchURL, + layout: sourceSet.layout, + appearance: sourceSet.appearance, + sourceOptions: sourceOptions, + repoOptions: repoOptions, + baseOptions: baseOptions, + repoRoot: pageContext.repoRoot, + branchBaseRef: source == .branch ? pageContext.branchBaseRef : nil + ) + return DiffViewerDeferredCompletion( + input: input, + fileURL: page.url, + viewerURL: page.viewerURL, + completedPageURLs: [page.url] + ) + } + private func nonEmptyGitDiffInput(source: DiffSource, context: DiffSourceContext) throws -> DiffInput { let input = try readGitDiffInput(source: source, context: context) guard !input.patch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -3175,6 +3728,13 @@ extension CMUXCLI { return error.localizedDescription } + private func diffViewerCommandError(_ error: Error) -> Error { + if let error = error as? EmptyDiffSourceError { + return CLIError(message: error.message) + } + return error + } + private func diffViewerSourceOptions( selected: DiffSource, urls: [DiffSource: URL] @@ -3345,116 +3905,22 @@ extension CMUXCLI { } } - private func writePendingDiffViewerHTML( - to url: URL, - title: String, - message: String, - pollForReplacement: Bool - ) throws { - let escapedTitle = htmlEscaped(title) - let escapedMessage = htmlEscaped(message) - let pendingAttribute = pollForReplacement ? " data-cmux-diff-pending=\"true\"" : "" - let pollScript = pollForReplacement ? """ - - """ : "" - let html = """ - - - - - - \(escapedTitle) - - - -
-

\(escapedTitle)

-

\(escapedMessage)

-
- \(pollScript) - - - """ - try writeDiffViewerPatchSidecar("", for: url) - try html.write(to: url, atomically: true, encoding: .utf8) - } - - private func diffViewerDirectory() throws -> URL { - let directory = URL(fileURLWithPath: "/tmp", isDirectory: true) - .appendingPathComponent("cmux-diff-viewer-\(getuid())", isDirectory: true) - try ensureSecureDiffViewerDirectory(directory) - pruneDiffViewerFiles(in: directory) - return directory - } - - private func ensureSecureDiffViewerDirectory(_ directory: URL) throws { - let path = directory.path - if mkdir(path, mode_t(0o700)) != 0 { - let mkdirErrno = errno - guard mkdirErrno == EEXIST else { - throw CLIError(message: "Failed to create diff viewer directory: \(posixErrorMessage(mkdirErrno))") - } - } + } try validateSecureDiffViewerDirectory(directory, repairPermissions: true) } @@ -3712,7 +4178,15 @@ extension CMUXCLI { func file(token: String, requestPath: String) throws -> DiffViewerAllowedFile? { lock.lock() if let files = filesByToken[token] { - let file = files[requestPath] + if let file = files[requestPath] { + lock.unlock() + return file + } + lock.unlock() + let refreshedFiles = try owner.loadDiffViewerHTTPManifestFiles(token: token, rootDirectory: rootDirectory) + lock.lock() + filesByToken[token] = refreshedFiles + let file = refreshedFiles[requestPath] lock.unlock() return file } @@ -4482,11 +4956,192 @@ extension CMUXCLI { return files } + private func diffViewerAllowedFilesWithExtraPage( + _ pageURL: URL, + files: [DiffViewerAllowedFile], + mapper: DiffViewerURLMapper + ) throws -> [DiffViewerAllowedFile] { + let extra = try mapper.allowedFile(fileURL: pageURL, mimeType: "text/html") + var seen: Set = [] + var merged: [DiffViewerAllowedFile] = [] + for file in [extra] + files where seen.insert(file.requestPath).inserted { + merged.append(file) + } + return merged + } + private func remotePatchURLMap(pageURL: URL, remoteURL: URL?) -> [String: URL] { guard let remoteURL else { return [:] } return [pageURL.standardizedFileURL.path: remoteURL] } + private func diffViewerShortcutPayload() -> [String: Any] { + Dictionary( + uniqueKeysWithValues: diffViewerShortcuts().map { action, shortcut in + (action.rawValue, shortcut.jsonObject) + } + ) + } + + private func diffViewerShortcuts() -> [DiffViewerShortcutAction: DiffViewerShortcut] { + var shortcuts = Dictionary( + uniqueKeysWithValues: DiffViewerShortcutAction.allCases.map { action in + (action, action.defaultShortcut) + } + ) + var managedActions = Set() + + for path in diffViewerShortcutSettingsPaths() { + guard let settings = diffViewerShortcutSettings(at: path) else { continue } + for (action, shortcut) in settings where !managedActions.contains(action) { + shortcuts[action] = shortcut + managedActions.insert(action) + } + } + + let primaryPath = Self.absoluteDiffViewerSettingsPath(Self.primarySettingsDisplayPath) + if let settings = diffViewerShortcutSettings(at: primaryPath) { + for (action, shortcut) in settings { + shortcuts[action] = shortcut + managedActions.insert(action) + } + } + + return shortcuts + } + + private func diffViewerShortcutSettingsPaths() -> [String] { + [ + Self.legacySettingsDisplayPath, + Self.fallbackSettingsDisplayPath, + ].map(Self.absoluteDiffViewerSettingsPath) + } + + private func diffViewerShortcutSettings(at path: String) -> [DiffViewerShortcutAction: DiffViewerShortcut]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + !data.isEmpty, + let sanitized = try? JSONCParser.preprocess(data: data), + let root = try? JSONSerialization.jsonObject(with: sanitized) as? [String: Any], + let shortcutsSection = root["shortcuts"] as? [String: Any] else { + return nil + } + + var rawBindings = shortcutsSection["bindings"] as? [String: Any] ?? [:] + for (key, rawValue) in shortcutsSection where key != "bindings" && key != "showModifierHoldHints" { + rawBindings[key] = rawValue + } + + var bindings: [DiffViewerShortcutAction: DiffViewerShortcut] = [:] + for action in DiffViewerShortcutAction.allCases { + guard let rawBinding = rawBindings[action.rawValue], + let shortcut = Self.parseDiffViewerShortcut(rawBinding) else { + continue + } + bindings[action] = shortcut + } + return bindings + } + + private static func parseDiffViewerShortcut(_ rawValue: Any) -> DiffViewerShortcut? { + if rawValue is NSNull { + return .unbound + } + if let rawString = rawValue as? String { + return parseDiffViewerShortcut(strokes: [rawString]) + } + if let rawStrings = rawValue as? [String] { + return rawStrings.isEmpty ? .unbound : parseDiffViewerShortcut(strokes: rawStrings) + } + return nil + } + + private static func parseDiffViewerShortcut(strokes: [String]) -> DiffViewerShortcut? { + guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + if strokes.count == 1, isUnboundDiffViewerShortcutToken(strokes[0]) { + return .unbound + } + let parsed = strokes.compactMap(parseDiffViewerShortcutStroke) + guard parsed.count == strokes.count, let first = parsed.first else { return nil } + return DiffViewerShortcut( + first: first, + second: parsed.count == 2 ? parsed[1] : nil + ) + } + + private static func parseDiffViewerShortcutStroke(_ rawValue: String) -> DiffViewerShortcutStroke? { + let rawParts = rawValue.split(separator: "+", omittingEmptySubsequences: false).map(String.init) + let parts = rawParts.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard let lastRawPart = rawParts.last, !lastRawPart.isEmpty else { return nil } + + var command = false + var shift = false + var option = false + var control = false + for modifier in parts.dropLast() { + switch modifier.lowercased() { + case "cmd", "command", "⌘": + command = true + case "shift", "⇧": + shift = true + case "opt", "option", "alt", "⌥": + option = true + case "ctrl", "control", "ctl", "⌃": + control = true + default: + return nil + } + } + + guard let key = parseDiffViewerShortcutKeyToken(lastRawPart) else { return nil } + return DiffViewerShortcutStroke(key: key, command: command, shift: shift, option: option, control: control) + } + + private static func parseDiffViewerShortcutKeyToken(_ rawValue: String) -> String? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return rawValue == " " ? "space" : nil + } + + switch trimmed.lowercased() { + case "space", "spacebar", "": + return "space" + case "slash": + return "/" + case "period", "dot": + return "." + case "comma": + return "," + default: + guard trimmed.count == 1 else { return nil } + return trimmed.lowercased() + } + } + + private static func isUnboundDiffViewerShortcutToken(_ rawValue: String) -> Bool { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "", "none", "clear", "unbound", "disabled": + return true + default: + return false + } + } + + private static func absoluteDiffViewerSettingsPath(_ rawPath: String) -> String { + let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + let expanded: String + if rawPath == "~" { + expanded = homePath + } else if rawPath.hasPrefix("~/") { + expanded = (homePath as NSString).appendingPathComponent(String(rawPath.dropFirst(2))) + } else { + expanded = rawPath + } + let absolute = (expanded as NSString).isAbsolutePath + ? expanded + : (FileManager.default.currentDirectoryPath as NSString).appendingPathComponent(expanded) + return URL(fileURLWithPath: absolute).standardizedFileURL.path + } + private func diffViewerPatchFileURL(for viewerURL: URL) -> URL { viewerURL.deletingPathExtension().appendingPathExtension("patch") } @@ -4536,6 +5191,65 @@ extension CMUXCLI { return viewerURL } + private func writeDiffViewerStatusHTML( + to viewerURL: URL, + title: String, + sourceLabel: String, + message: String, + isError: Bool, + pollForReplacement: Bool, + layout: String, + appearance: DiffViewerAppearance, + sourceOptions: [DiffViewerSourceOption], + repoOptions: [DiffViewerSourceOption] = [], + baseOptions: [DiffViewerSourceOption] = [], + repoRoot: String? = nil, + branchBaseRef: String? = nil + ) throws { + try writeDiffViewerHTML( + to: viewerURL, + patch: "", + title: title, + sourceLabel: sourceLabel, + externalURL: nil, + layout: layout, + appearance: appearance, + sourceOptions: sourceOptions, + repoOptions: repoOptions, + baseOptions: baseOptions, + repoRoot: repoRoot, + branchBaseRef: branchBaseRef, + statusMessage: message, + statusIsError: isError, + pollForReplacement: pollForReplacement + ) + } + + private func writeDiffViewerRedirectHTML(to viewerURL: URL, title: String, targetURL: URL) throws { + try writeDiffViewerPatchSidecar("", for: viewerURL) + let target = targetURL.absoluteString + let targetLiteral = try jsonStringLiteral(target) + let escapedTitle = htmlEscaped(title) + let escapedTarget = htmlEscaped(target) + let html = """ + + + + + + + \(escapedTitle) + + + + + + """ + try html.write(to: viewerURL, atomically: true, encoding: .utf8) + } + private func writeDiffViewerHTML( to viewerURL: URL, patch: String, @@ -4549,7 +5263,10 @@ extension CMUXCLI { repoOptions: [DiffViewerSourceOption] = [], baseOptions: [DiffViewerSourceOption] = [], repoRoot: String? = nil, - branchBaseRef: String? = nil + branchBaseRef: String? = nil, + statusMessage: String? = nil, + statusIsError: Bool = false, + pollForReplacement: Bool = false ) throws { if remotePatchURL == nil { try writeDiffViewerPatchSidecar(patch, for: viewerURL) @@ -4562,11 +5279,19 @@ extension CMUXCLI { "layout": layout, "appearance": appearance.jsonObject, "labels": labels.jsonObject, + "shortcuts": diffViewerShortcutPayload(), "sourceOptions": sourceOptions.map(\.jsonObject), "repoOptions": repoOptions.map(\.jsonObject), "baseOptions": baseOptions.map(\.jsonObject), "generatedAt": ISO8601DateFormatter().string(from: Date()) ] + if let statusMessage { + payload["statusMessage"] = statusMessage + payload["statusIsError"] = statusIsError + } + if pollForReplacement { + payload["pendingReplacement"] = true + } if let externalURL { payload["externalURL"] = externalURL } @@ -4598,11 +5323,12 @@ extension CMUXCLI { let additionsLabel = htmlEscaped(labels["additions"]) let deletionsLabel = htmlEscaped(labels["deletions"]) let diffViewerLabel = htmlEscaped(labels["diffViewer"]) - let loadingDiffLabel = htmlEscaped(labels["loadingDiff"]) + let loadingDiffLabel = htmlEscaped(statusMessage ?? labels["loadingDiff"]) let htmlLanguage = Locale.current.language.languageCode?.identifier ?? "en" + let pendingAttribute = pollForReplacement ? " data-cmux-diff-pending=\"true\"" : "" let html = """ - + @@ -5104,6 +5830,13 @@ extension CMUXCLI { display: none; } } + body[data-status-only="true"] #content { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "viewer"; + } + body[data-status-only="true"] #files-sidebar { + display: none; + } @media (prefers-reduced-motion: reduce) { #files-sidebar { transition: none; @@ -5127,12 +5860,35 @@ extension CMUXCLI { line-height: var(--cmux-diff-ui-line-height); color: color-mix(in lab, var(--cmux-diff-fg) 70%, var(--cmux-diff-bg)); } + #status[data-pending="true"] { + display: inline-flex; + align-items: center; + gap: 10px; + } + #status[data-pending="true"]::before { + content: ""; + width: 16px; + height: 16px; + flex: 0 0 auto; + border: 2px solid color-mix(in lab, var(--cmux-diff-fg) 20%, transparent); + border-top-color: color-mix(in lab, var(--cmux-diff-fg) 70%, var(--cmux-diff-bg)); + border-radius: 50%; + animation: cmuxDiffPendingSpin 800ms linear infinite; + } #status[data-error="true"] { color: light-dark(#b42318, #ff8a80); } + @keyframes cmuxDiffPendingSpin { + to { transform: rotate(360deg); } + } + @media (prefers-reduced-motion: reduce) { + #status[data-pending="true"]::before { + animation: none; + } + } - +
@@ -5237,16 +5993,22 @@ extension CMUXCLI { setupNavigationSelector(repoSelect, payload.repoOptions ?? [], payload.repoRoot ?? "", label("repoPath")); setupNavigationSelector(baseSelect, payload.baseOptions ?? [], payload.branchBaseRef ?? "", label("branchBase")); const scheduleRender = globalThis.queueMicrotask ?? ((callback) => setTimeout(callback, 0)); - scheduleRender(() => { - renderDiff().catch((error) => { - console.error("cmux diff viewer render failed", error); - status.dataset.error = "true"; - status.textContent = label("renderFailed"); + if (payload.pendingReplacement === true) { + showStatusMessage(payload.statusMessage ?? label("loadingDiff"), { pending: true }); + waitForReplacement(); + } else if (typeof payload.statusMessage === "string" && payload.statusMessage.length > 0) { + showStatusMessage(payload.statusMessage, { error: payload.statusIsError === true }); + } else { + scheduleRender(() => { + renderDiff().catch((error) => { + console.error("cmux diff viewer render failed", error); + showStatusMessage(label("renderFailed"), { error: true }); + }); }); - }); + } async function renderDiff() { - status.textContent = label("loadingRenderer"); + showStatusMessage(label("loadingRenderer")); const { CodeView, getFiletypeFromFileName, @@ -5263,7 +6025,7 @@ extension CMUXCLI { registerGhosttyTheme(registerCustomTheme, payload.appearance.themes.light); registerGhosttyTheme(registerCustomTheme, payload.appearance.themes.dark); - status.textContent = label("parsingDiff"); + showStatusMessage(label("parsingDiff")); setWorkerPoolStatus("loading"); workerPool = await createCodeViewWorkerPool(); setupJumpSelector(diffItems); @@ -5293,6 +6055,46 @@ extension CMUXCLI { } } + function showStatusMessage(message, options = {}) { + if (!status.isConnected) { + viewerElement.replaceChildren(status); + } + document.body.dataset.statusOnly = options.pending === true || options.error === true ? "true" : "false"; + status.dataset.error = options.error === true ? "true" : "false"; + status.dataset.pending = options.pending === true ? "true" : "false"; + status.textContent = message; + } + + function replaceDocumentWith(text) { + document.open(); + document.write(text); + document.close(); + } + + async function applyReplacementFrom(response) { + const text = await response.text(); + if (!response.ok) { + showStatusMessage(label("renderFailed"), { error: true }); + return false; + } + if (text.includes("data-cmux-diff-pending=\\"true\\"")) { + return false; + } + replaceDocumentWith(text); + return true; + } + + async function waitForReplacement() { + try { + const response = await fetch("/__cmux_diff_viewer_wait" + location.pathname, { cache: "no-store" }); + await applyReplacementFrom(response); + } catch (error) { + document.documentElement.dataset.cmuxDiffWait = "failed"; + showStatusMessage(label("renderFailed"), { error: true }); + console.warn("cmux diff viewer deferred load failed", error); + } + } + async function createCodeViewWorkerPool() { if (typeof Worker === "undefined") { return null; @@ -6016,9 +6818,140 @@ extension CMUXCLI { setOptionsMenuOpen(false); } }); + setupKeyboardShortcuts(); updateToolbarState(); } + function setupKeyboardShortcuts() { + const shortcuts = payload.shortcuts ?? {}; + const scrollDownShortcut = normalizeShortcut(shortcuts.diffViewerScrollDown); + const scrollUpShortcut = normalizeShortcut(shortcuts.diffViewerScrollUp); + const scrollBottomShortcut = normalizeShortcut(shortcuts.diffViewerScrollToBottom); + const scrollTopShortcut = normalizeShortcut(shortcuts.diffViewerScrollToTop); + const fileSearchShortcut = normalizeShortcut(shortcuts.diffViewerOpenFileSearch); + let pendingChord = null; + let chordTimeout = 0; + document.addEventListener("keydown", (event) => { + if (event.defaultPrevented || isTypingShortcutTarget(event.target)) { + return; + } + if (pendingChord && !shortcutStrokeMatchesEvent(pendingChord.shortcut.second, event)) { + clearPendingChord(); + } + if (pendingChord && shortcutStrokeMatchesEvent(pendingChord.shortcut.second, event)) { + event.preventDefault(); + pendingChord.action(); + clearPendingChord(); + return; + } + if (shortcutMatchesEvent(scrollDownShortcut, event)) { + event.preventDefault(); + scrollViewerBy(1); + return; + } + if (shortcutMatchesEvent(scrollUpShortcut, event)) { + event.preventDefault(); + scrollViewerBy(-1); + return; + } + if (shortcutMatchesEvent(scrollBottomShortcut, event)) { + event.preventDefault(); + viewerElement.scrollTo({ top: viewerElement.scrollHeight, behavior: "auto" }); + return; + } + if (shortcutMatchesEvent(fileSearchShortcut, event) && fileTree) { + event.preventDefault(); + setFilesVisible(true); + setFileSearchOpen(true); + return; + } + if (shortcutStartsChord(scrollTopShortcut, event)) { + event.preventDefault(); + pendingChord = { + shortcut: scrollTopShortcut, + action: () => viewerElement.scrollTo({ top: 0, behavior: "auto" }), + }; + chordTimeout = setTimeout(clearPendingChord, 700); + } + }); + + function clearPendingChord() { + pendingChord = null; + if (chordTimeout !== 0) { + clearTimeout(chordTimeout); + chordTimeout = 0; + } + } + } + + function normalizeShortcut(rawShortcut) { + if (!rawShortcut || rawShortcut.unbound === true || !rawShortcut.first) { + return null; + } + return { + first: normalizeShortcutStroke(rawShortcut.first), + second: rawShortcut.second ? normalizeShortcutStroke(rawShortcut.second) : null, + }; + } + + function normalizeShortcutStroke(rawStroke) { + return { + key: String(rawStroke?.key ?? "").toLowerCase(), + command: rawStroke?.command === true, + shift: rawStroke?.shift === true, + option: rawStroke?.option === true, + control: rawStroke?.control === true, + }; + } + + function shortcutMatchesEvent(shortcut, event) { + return shortcut && !shortcut.second && shortcutStrokeMatchesEvent(shortcut.first, event); + } + + function shortcutStartsChord(shortcut, event) { + return shortcut && shortcut.second && shortcutStrokeMatchesEvent(shortcut.first, event); + } + + function shortcutStrokeMatchesEvent(stroke, event) { + if (!stroke || event.metaKey !== stroke.command || event.ctrlKey !== stroke.control || event.altKey !== stroke.option) { + return false; + } + if (event.shiftKey !== stroke.shift) { + return false; + } + const eventKey = normalizedShortcutEventKey(event); + return eventKey === stroke.key; + } + + function normalizedShortcutEventKey(event) { + if (event.code === "Space") { + return "space"; + } + if (typeof event.key !== "string" || event.key.length === 0) { + return ""; + } + if (event.key.length === 1) { + return event.key.toLowerCase(); + } + return event.key.toLowerCase(); + } + + function isTypingShortcutTarget(target) { + const element = target instanceof Element ? target : null; + if (!element) { + return false; + } + if (element.closest("input, textarea, select, [contenteditable='true']")) { + return true; + } + return false; + } + + function scrollViewerBy(direction) { + const amount = Math.max(80, Math.floor(viewerElement.clientHeight * 0.38)); + viewerElement.scrollBy({ top: direction * amount, behavior: "auto" }); + } + function codeViewOptions() { return { layout: { paddingTop: 0, gap: 1, paddingBottom: 0 }, @@ -6325,8 +7258,7 @@ extension CMUXCLI { sourceSelect.value = selected?.value ?? ""; return; } - status.dataset.error = "false"; - status.textContent = label("loadingDiff"); + showStatusMessage(label("loadingDiff"), { pending: true }); window.location.href = next.url; }); } @@ -6362,8 +7294,7 @@ extension CMUXCLI { selectElement.value = selected?.value ?? fallbackValue ?? ""; return; } - status.dataset.error = "false"; - status.textContent = label("loadingDiff"); + showStatusMessage(label("loadingDiff"), { pending: true }); window.location.href = next.url; }); } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d4a17085e6..a32695e03f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4159,6 +4159,131 @@ } } }, + "diffViewer.loadingDiffTarget": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "差分を読み込み中: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Loading diff: %@" + } + } + } + }, "diffViewer.loadingRenderer": { "extractionState": "manual", "localizations": { @@ -106588,21 +106713,21 @@ } }, "settings.search.alias.setting.app.default-terminal": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "app.defaultTerminal default terminal ssh links command tool unix executable launch services handler" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "app.defaultTerminal default terminal ssh links command tool unix executable launch services handler デフォルトターミナル SSH リンク 実行ファイル" - } - } + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "app.defaultTerminal default terminal ssh links command tool unix executable launch services handler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "app.defaultTerminal default terminal ssh links command tool unix executable launch services handler デフォルトターミナル SSH リンク 実行ファイル" + } } + } }, "settings.search.alias.setting.app.new-workspace-placement": { "extractionState": "manual", @@ -166626,6 +166751,631 @@ } } } + }, + "shortcut.diffViewerScrollDown.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "差分ビューア: 下へスクロール" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Down" + } + } + } + }, + "shortcut.diffViewerScrollUp.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "差分ビューア: 上へスクロール" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll Up" + } + } + } + }, + "shortcut.diffViewerScrollToBottom.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "差分ビューア: 末尾へスクロール" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Bottom" + } + } + } + }, + "shortcut.diffViewerScrollToTop.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "差分ビューア: 先頭へスクロール" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Scroll to Top" + } + } + } + }, + "shortcut.diffViewerOpenFileSearch.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "差分ビューア: ファイル検索を開く" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Diff Viewer: Open File Search" + } + } + } } } } diff --git a/Sources/App/ShortcutBareStartRouting.swift b/Sources/App/ShortcutBareStartRouting.swift index ef13b76114..1cb18ec0f8 100644 --- a/Sources/App/ShortcutBareStartRouting.swift +++ b/Sources/App/ShortcutBareStartRouting.swift @@ -16,6 +16,7 @@ enum KeyboardShortcutBareStartCache { let resolvedKeys = Set( KeyboardShortcutSettings.Action.allCases.compactMap { action -> String? in guard action != .showHideAllWindows else { return nil } + guard !action.isBrowserContentShortcut else { return nil } return KeyboardShortcutSettings.shortcut(for: action).bareShortcutStartKey } ) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c8d579d646..6f3f35a74a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11843,6 +11843,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // orphaned, breaking that keystroke for the focused terminal/browser // input. guard action != .showHideAllWindows && action != .globalSearch else { return false } + guard !action.isBrowserContentShortcut else { return false } return KeyboardShortcutSettings.shortcut(for: action).hasChord } } @@ -14353,6 +14354,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent fileprivate func shouldForwardBrowserSurfaceShortcutToTerminal(_ event: NSEvent) -> Bool { return KeyboardShortcutSettings.Action.allCases.contains { $0.shortcutContext == .browserPanel && + !$0.isBrowserContentShortcut && matchConfiguredShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: $0)) } } diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index 43e9d3f484..f43bc1f54d 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -49,6 +49,12 @@ extension KeyboardShortcutSettings.Action { var shortcutContext: ShortcutContext { switch self { + case .diffViewerScrollDown, + .diffViewerScrollUp, + .diffViewerScrollToBottom, + .diffViewerScrollToTop, + .diffViewerOpenFileSearch: + return .browserPanel case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock: return .rightSidebarFocus case .renameTab, .renameWorkspace: diff --git a/Sources/KeyboardShortcutRecorder.swift b/Sources/KeyboardShortcutRecorder.swift index d44998d2a3..84de80ab5f 100644 --- a/Sources/KeyboardShortcutRecorder.swift +++ b/Sources/KeyboardShortcutRecorder.swift @@ -17,6 +17,7 @@ struct KeyboardShortcutRecorder: View { var onUndoButtonPressed: (() -> Void)? = nil var hasPendingRejection: Bool = false var isDisabled: Bool = false + var firstStrokeRequiresModifier: Bool = true var onRecordingChanged: (Bool) -> Void = { _ in } var onRecorderFeedbackChanged: (ShortcutRecorderRejectedAttempt?) -> Void = { _ in } @State private var isRecording = false @@ -40,6 +41,7 @@ struct KeyboardShortcutRecorder: View { shortcut: $shortcut, isRecording: $isRecording, hasPendingRejection: hasPendingRejection, + firstStrokeRequiresModifier: firstStrokeRequiresModifier, displayString: displayString, transformRecordedShortcut: transformRecordedShortcut, onRecordingChanged: onRecordingChanged, @@ -129,6 +131,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable { @Binding var shortcut: StoredShortcut @Binding var isRecording: Bool var hasPendingRejection: Bool = false + var firstStrokeRequiresModifier: Bool = true let displayString: (StoredShortcut) -> String let transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution let onRecordingChanged: (Bool) -> Void @@ -138,6 +141,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable { let button = ShortcutRecorderNSButton() button.shortcut = shortcut button.displayString = displayString + button.firstStrokeRequiresModifier = firstStrokeRequiresModifier button.transformRecordedShortcut = transformRecordedShortcut button.onShortcutRecorded = { newShortcut in shortcut = newShortcut @@ -155,6 +159,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable { func updateNSView(_ nsView: ShortcutRecorderNSButton, context: Context) { nsView.shortcut = shortcut nsView.displayString = displayString + nsView.firstStrokeRequiresModifier = firstStrokeRequiresModifier nsView.transformRecordedShortcut = transformRecordedShortcut nsView.onRecordingChanged = { recording in isRecording = recording @@ -182,6 +187,7 @@ final class ShortcutRecorderNSButton: NSButton { var transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution = { .accepted($0) } + var firstStrokeRequiresModifier = true var onShortcutRecorded: ((StoredShortcut) -> Void)? var onRecordingChanged: ((Bool) -> Void)? var onRecorderFeedbackChanged: ((ShortcutRecorderRejectedAttempt?) -> Void)? @@ -308,7 +314,7 @@ final class ShortcutRecorderNSButton: NSButton { } if pendingChordStart == nil { - switch ShortcutStroke.recordingResult(from: event, requireModifier: true) { + switch ShortcutStroke.recordingResult(from: event, requireModifier: firstStrokeRequiresModifier) { case let .accepted(firstStroke): let firstShortcut = StoredShortcut(first: firstStroke) switch transformRecordedShortcut(firstShortcut) { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index ca12a0f1f3..a363b8ffba 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -146,6 +146,11 @@ enum KeyboardShortcutSettings { case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole case toggleReactGrab + case diffViewerScrollDown + case diffViewerScrollUp + case diffViewerScrollToBottom + case diffViewerScrollToTop + case diffViewerOpenFileSearch var id: String { rawValue } @@ -230,6 +235,11 @@ enum KeyboardShortcutSettings { case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab") + case .diffViewerScrollDown: return String(localized: "shortcut.diffViewerScrollDown.label", defaultValue: "Diff Viewer: Scroll Down") + case .diffViewerScrollUp: return String(localized: "shortcut.diffViewerScrollUp.label", defaultValue: "Diff Viewer: Scroll Up") + case .diffViewerScrollToBottom: return String(localized: "shortcut.diffViewerScrollToBottom.label", defaultValue: "Diff Viewer: Scroll to Bottom") + case .diffViewerScrollToTop: return String(localized: "shortcut.diffViewerScrollToTop.label", defaultValue: "Diff Viewer: Scroll to Top") + case .diffViewerOpenFileSearch: return String(localized: "shortcut.diffViewerOpenFileSearch.label", defaultValue: "Diff Viewer: Open File Search") } } @@ -419,6 +429,19 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) case .toggleReactGrab: return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) + case .diffViewerScrollDown: + return StoredShortcut(key: "j", command: false, shift: false, option: false, control: false) + case .diffViewerScrollUp: + return StoredShortcut(key: "k", command: false, shift: false, option: false, control: false) + case .diffViewerScrollToBottom: + return StoredShortcut(key: "g", command: false, shift: true, option: false, control: false) + case .diffViewerScrollToTop: + return StoredShortcut( + first: ShortcutStroke(key: "g", command: false, shift: false, option: false, control: false), + second: ShortcutStroke(key: "g", command: false, shift: false, option: false, control: false) + ) + case .diffViewerOpenFileSearch: + return StoredShortcut(key: "/", command: false, shift: false, option: false, control: false) } } @@ -435,6 +458,32 @@ enum KeyboardShortcutSettings { } } + var allowsBareFirstStroke: Bool { + switch self { + case .diffViewerScrollDown, + .diffViewerScrollUp, + .diffViewerScrollToBottom, + .diffViewerScrollToTop, + .diffViewerOpenFileSearch: + return true + default: + return false + } + } + + var isBrowserContentShortcut: Bool { + switch self { + case .diffViewerScrollDown, + .diffViewerScrollUp, + .diffViewerScrollToBottom, + .diffViewerScrollToTop, + .diffViewerOpenFileSearch: + return true + default: + return false + } + } + func displayedShortcutString(for shortcut: StoredShortcut) -> String { if shortcut.isUnbound { return shortcut.displayString @@ -2293,14 +2342,14 @@ extension ShortcutStroke { } extension StoredShortcut { - static func parseConfig(_ rawValue: String) -> StoredShortcut? { + static func parseConfig(_ rawValue: String, allowBareFirstStroke: Bool = false) -> StoredShortcut? { if isUnboundConfigToken(rawValue) { return .unbound } - return parseConfig(strokes: [rawValue]) + return parseConfig(strokes: [rawValue], allowBareFirstStroke: allowBareFirstStroke) } - static func parseConfig(strokes: [String]) -> StoredShortcut? { + static func parseConfig(strokes: [String], allowBareFirstStroke: Bool = false) -> StoredShortcut? { guard !strokes.isEmpty, strokes.count <= 2 else { return nil } if strokes.count == 1, let rawValue = strokes.first, isUnboundConfigToken(rawValue) { return .unbound @@ -2309,7 +2358,7 @@ extension StoredShortcut { guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { return nil } - guard !firstStroke.modifierFlags.isEmpty || firstStroke.key == "space" else { return nil } + guard allowBareFirstStroke || !firstStroke.modifierFlags.isEmpty || firstStroke.key == "space" else { return nil } let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil return StoredShortcut(first: firstStroke, second: secondStroke) } diff --git a/Sources/KeyboardShortcutSettingsControls.swift b/Sources/KeyboardShortcutSettingsControls.swift index caff0b6af3..3a8217cfb1 100644 --- a/Sources/KeyboardShortcutSettingsControls.swift +++ b/Sources/KeyboardShortcutSettingsControls.swift @@ -202,6 +202,7 @@ struct ShortcutRecorderSettingsControl: View { onUndoButtonPressed: rejectedAttempt != nil ? { rejectedAttempt = nil } : nil, hasPendingRejection: rejectedAttempt != nil, isDisabled: isDisabled, + firstStrokeRequiresModifier: !action.allowsBareFirstStroke, onRecorderFeedbackChanged: { rejectedAttempt = $0 } ) .onChange(of: shortcut) { _, _ in diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 3e1811ac71..3af14d4902 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -981,9 +981,14 @@ final class CmuxSettingsFileStore { ) -> StoredShortcut? { let shortcut: StoredShortcut? = { if rawValue is NSNull { return .unbound } - if let stroke = jsonString(rawValue) { return StoredShortcut.parseConfig(stroke) } + if let stroke = jsonString(rawValue) { + return StoredShortcut.parseConfig(stroke, allowBareFirstStroke: action.allowsBareFirstStroke) + } if let strokes = jsonStringArray(rawValue) { - return strokes.isEmpty ? .unbound : StoredShortcut.parseConfig(strokes: strokes) + return strokes.isEmpty ? .unbound : StoredShortcut.parseConfig( + strokes: strokes, + allowBareFirstStroke: action.allowsBareFirstStroke + ) } return nil }() diff --git a/cmuxTests/CMUXOpenCommandTests.swift b/cmuxTests/CMUXOpenCommandTests.swift index ba5ad35acb..4790e60cff 100644 --- a/cmuxTests/CMUXOpenCommandTests.swift +++ b/cmuxTests/CMUXOpenCommandTests.swift @@ -204,6 +204,10 @@ final class CMUXOpenCommandTests: XCTestCase { .appendingPathComponent(".config", isDirectory: true) .appendingPathComponent("ghostty", isDirectory: true) .appendingPathComponent("config", isDirectory: false) + let cmuxConfigURL = homeURL + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("cmux.json", isDirectory: false) let cmuxAppSupportConfigURL = homeURL .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", isDirectory: true) @@ -213,6 +217,7 @@ final class CMUXOpenCommandTests: XCTestCase { let ghosttyThemesURL = ghosttyResourcesURL.appendingPathComponent("themes", isDirectory: true) try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: ghosttyConfigURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cmuxConfigURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: cmuxAppSupportConfigURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: ghosttyThemesURL, withIntermediateDirectories: true) try """ @@ -267,6 +272,17 @@ final class CMUXOpenCommandTests: XCTestCase { try ghosttyConfigContents.write(to: ghosttyConfigURL, atomically: true, encoding: .utf8) try ghosttyConfigContents.write(to: cmuxAppSupportConfigURL, atomically: true, encoding: .utf8) try """ + { + "shortcuts": { + "bindings": { + "diffViewerScrollDown": "ctrl+j", + "diffViewerScrollToTop": ["g", "g"], + "diffViewerOpenFileSearch": null + } + } + } + """.write(to: cmuxConfigURL, atomically: true, encoding: .utf8) + try """ diff --git a/hello.txt b/hello.txt index 8ab686e..d95f3ad 100644 --- a/hello.txt @@ -352,6 +368,20 @@ final class CMUXOpenCommandTests: XCTestCase { let html = try String(contentsOf: viewerFileURL, encoding: .utf8) let patchText = try String(contentsOf: patchSidecarURL, encoding: .utf8) let viewerPayload = try diffViewerPayload(from: html) + let shortcuts = try XCTUnwrap(viewerPayload["shortcuts"] as? [String: Any]) + let scrollDown = try XCTUnwrap(shortcuts["diffViewerScrollDown"] as? [String: Any]) + let scrollDownFirst = try XCTUnwrap(scrollDown["first"] as? [String: Any]) + XCTAssertEqual(scrollDownFirst["key"] as? String, "j") + XCTAssertEqual(scrollDownFirst["control"] as? Bool, true) + let scrollUp = try XCTUnwrap(shortcuts["diffViewerScrollUp"] as? [String: Any]) + let scrollUpFirst = try XCTUnwrap(scrollUp["first"] as? [String: Any]) + XCTAssertEqual(scrollUpFirst["key"] as? String, "k") + XCTAssertEqual(scrollUpFirst["control"] as? Bool, false) + let scrollTop = try XCTUnwrap(shortcuts["diffViewerScrollToTop"] as? [String: Any]) + XCTAssertEqual((try XCTUnwrap(scrollTop["first"] as? [String: Any]))["key"] as? String, "g") + XCTAssertEqual((try XCTUnwrap(scrollTop["second"] as? [String: Any]))["key"] as? String, "g") + let fileSearch = try XCTUnwrap(shortcuts["diffViewerOpenFileSearch"] as? [String: Any]) + XCTAssertEqual(fileSearch["unbound"] as? Bool, true) let files = try diffViewerAllowedFiles(for: rawURL, from: params) XCTAssertTrue(html.contains("Review diff"), html) XCTAssertTrue(html.contains("id=\"files-sidebar\""), html) @@ -632,10 +662,85 @@ final class CMUXOpenCommandTests: XCTestCase { let unstagedOption = try XCTUnwrap(sourceOptions.first { $0["value"] as? String == "unstaged" }) XCTAssertEqual(stagedOption["selected"] as? Bool, true) XCTAssertEqual(unstagedOption["selected"] as? Bool, false) + let unstagedURLString = try diffViewerOptionURL(value: "unstaged", in: sourceOptions) + let unstagedFileURL = try diffViewerHTMLFileURL(for: unstagedURLString, from: stagedFallback.params) + let unstagedHTML = try String(contentsOf: unstagedFileURL, encoding: .utf8) + XCTAssertTrue(unstagedHTML.contains("No unstaged changes to diff."), unstagedHTML) + XCTAssertFalse(unstagedHTML.contains("+two"), unstagedHTML) let gitLog = try String(contentsOf: gitLogURL, encoding: .utf8) XCTAssertFalse(gitLog.contains(plainSiblingURL.path), gitLog) } + func testDiffCommandKeepsSelectedEmptyErrorWhenFallbackProbeFails() throws { + let cliPath = try bundledCLIPath() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let repoURL = rootURL.appendingPathComponent("repo", isDirectory: true) + let fileURL = repoURL.appendingPathComponent("story.txt") + try FileManager.default.createDirectory(at: repoURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + try runGit(["init"], in: repoURL) + try runGit(["checkout", "-b", "main"], in: repoURL) + try runGit(["config", "user.name", "cmux tests"], in: repoURL) + try runGit(["config", "user.email", "cmux@example.invalid"], in: repoURL) + try "one\n".write(to: fileURL, atomically: true, encoding: .utf8) + try runGit(["add", "story.txt"], in: repoURL) + try runGit(["commit", "-m", "initial"], in: repoURL) + + let socketPath = makeSocketPath("diff-empty") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = Self.v2Payload(from: line), + let id = payload["id"] as? String, + let method = payload["method"] as? String, + method == "browser.open_split", + let params = payload["params"] as? [String: Any], + let rawURL = params["url"] as? String else { + return Self.v2Response(id: "unknown", ok: false, error: ["code": "unexpected"]) + } + return Self.v2Response( + id: id, + ok: true, + result: ["surface_id": "surface-id", "pane_id": "pane-id", "url": rawURL] + ) + } + + let result = runCLI( + cliPath: cliPath, + socketPath: socketPath, + arguments: ["diff", "--unstaged"], + currentDirectoryURL: repoURL + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertNotEqual(result.status, 0) + XCTAssertFalse(result.stdout.contains("OK surface="), result.stdout) + XCTAssertTrue(result.stderr.contains("No unstaged changes to diff."), result.stderr) + XCTAssertFalse(result.stderr.contains("EmptyDiffSourceError"), result.stderr) + XCTAssertFalse(result.stderr.contains("workspace and surface"), result.stderr) + + let commandPayload = try XCTUnwrap( + state.commands.compactMap { Self.v2Payload(from: $0) }.first { payload in + payload["method"] as? String == "browser.open_split" + } + ) + let params = try XCTUnwrap(commandPayload["params"] as? [String: Any]) + let rawURL = try XCTUnwrap(params["url"] as? String) + let openedFileURL = try diffViewerHTMLFileURL(for: rawURL, from: params) + let viewerFileURL = try resolvedDiffViewerHTMLFileURL(openedFileURL, from: params) + let html = try String(contentsOf: viewerFileURL, encoding: .utf8) + XCTAssertTrue(html.contains("No unstaged changes to diff."), html) + XCTAssertFalse(html.contains("No last-turn diff baseline recorded"), html) + } + func testDiffCommandDoesNotFallbackFromLastTurnBaselineError() throws { let cliPath = try bundledCLIPath() let rootURL = FileManager.default.temporaryDirectory @@ -1409,6 +1514,168 @@ final class CMUXOpenCommandTests: XCTestCase { XCTAssertTrue(large.patch.contains("+new line 4999"), large.patch) } + func testDiffCommandOpensPendingViewerBeforeGitDiffCompletes() throws { + let cliPath = try bundledCLIPath() + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let repoURL = rootURL.appendingPathComponent("repo", isDirectory: true) + let fakeBinURL = rootURL.appendingPathComponent("bin", isDirectory: true) + let fakeGitURL = fakeBinURL.appendingPathComponent("git", isDirectory: false) + let diffStartedURL = rootURL.appendingPathComponent("diff-started", isDirectory: false) + let releaseDiffURL = rootURL.appendingPathComponent("release-diff", isDirectory: false) + let alternateStartedURL = rootURL.appendingPathComponent("alternate-started", isDirectory: false) + let releaseAlternateURL = rootURL.appendingPathComponent("release-alternate", isDirectory: false) + try FileManager.default.createDirectory(at: repoURL.appendingPathComponent(".git", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: fakeBinURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + try """ + #!/bin/sh + if [ "${1:-}" = "-C" ]; then + shift 2 + fi + if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "--show-toplevel" ]; then + printf '%s\\n' "$CMUX_FAKE_GIT_REPO_ROOT" + exit 0 + fi + if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "--verify" ]; then + : > "$CMUX_FAKE_GIT_STARTED" + while [ ! -f "$CMUX_FAKE_GIT_RELEASE" ]; do + sleep 0.05 + done + exit 1 + fi + if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ]; then + : > "$CMUX_FAKE_GIT_ALTERNATE_STARTED" + while [ ! -f "$CMUX_FAKE_GIT_RELEASE_ALTERNATE" ]; do + sleep 0.05 + done + exit 0 + fi + if [ "${1:-}" = "diff" ]; then + : > "$CMUX_FAKE_GIT_STARTED" + while [ ! -f "$CMUX_FAKE_GIT_RELEASE" ]; do + sleep 0.05 + done + cat <<'PATCH' + diff --git a/large.txt b/large.txt + index 1111111..2222222 100644 + --- a/large.txt + +++ b/large.txt + @@ -1 +1 @@ + -old line + +new line + PATCH + exit 0 + fi + if [ "${1:-}" = "for-each-ref" ]; then + exit 0 + fi + exit 1 + """.write(to: fakeGitURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeGitURL.path) + + let socketPath = makeSocketPath("diff-pending") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let openedURLBox = AsyncValueBox(nil) + let openedHTMLURLBox = AsyncValueBox(nil) + let pendingHTMLBox = AsyncValueBox(nil) + let diffHadStartedWhenOpenedBox = AsyncValueBox(nil) + let openHandled = expectation(description: "browser opened before fake git diff completed") + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverClosed = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = Self.v2Payload(from: line), + let id = payload["id"] as? String, + let method = payload["method"] as? String, + method == "browser.open_split", + let params = payload["params"] as? [String: Any], + let rawURL = params["url"] as? String else { + return Self.v2Response(id: "unknown", ok: false, error: ["code": "unexpected"]) + } + openedURLBox.set(rawURL) + diffHadStartedWhenOpenedBox.set(FileManager.default.fileExists(atPath: diffStartedURL.path)) + if let htmlURL = Self.diffViewerHTMLFileURLFromHTTPManifest(for: rawURL) { + openedHTMLURLBox.set(htmlURL) + pendingHTMLBox.set(try? String(contentsOf: htmlURL, encoding: .utf8)) + } + openHandled.fulfill() + return Self.v2Response( + id: id, + ok: true, + result: ["surface_id": "surface-id", "pane_id": "pane-id", "url": rawURL] + ) + } + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(fakeBinURL.path):\(environment["PATH"] ?? "")" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + environment["CMUX_FAKE_GIT_REPO_ROOT"] = repoURL.path + environment["CMUX_FAKE_GIT_STARTED"] = diffStartedURL.path + environment["CMUX_FAKE_GIT_RELEASE"] = releaseDiffURL.path + environment["CMUX_FAKE_GIT_ALTERNATE_STARTED"] = alternateStartedURL.path + environment["CMUX_FAKE_GIT_RELEASE_ALTERNATE"] = releaseAlternateURL.path + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = ["diff", "--unstaged", "--cwd", repoURL.path, "--title", "Slow diff", "--no-focus"] + process.environment = environment + process.currentDirectoryURL = repoURL + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + try process.run() + defer { terminateProcess(process) } + + wait(for: [openHandled], timeout: 5) + XCTAssertNotNil(openedURLBox.get()) + XCTAssertEqual(diffHadStartedWhenOpenedBox.get() ?? true, false) + XCTAssertTrue(pendingHTMLBox.get()?.contains("data-cmux-diff-pending=\"true\"") == true, pendingHTMLBox.get() ?? "") + XCTAssertTrue(pendingHTMLBox.get()?.contains("data-status-only=\"true\"") == true, pendingHTMLBox.get() ?? "") + XCTAssertTrue(pendingHTMLBox.get()?.contains("id=\"toolbar\"") == true, pendingHTMLBox.get() ?? "") + XCTAssertTrue(pendingHTMLBox.get()?.contains("id=\"viewer\"") == true, pendingHTMLBox.get() ?? "") + XCTAssertFalse(FileManager.default.fileExists(atPath: releaseDiffURL.path)) + FileManager.default.createFile(atPath: releaseDiffURL.path, contents: Data()) + let openingHTMLURL = try XCTUnwrap(openedHTMLURLBox.get()) + XCTAssertTrue(waitUntil(timeout: 5) { + let html = (try? String(contentsOf: openingHTMLURL, encoding: .utf8)) ?? "" + return html.contains("data-cmux-diff-redirect=") + && FileManager.default.fileExists(atPath: alternateStartedURL.path) + }) + XCTAssertFalse(FileManager.default.fileExists(atPath: releaseAlternateURL.path)) + XCTAssertTrue(process.isRunning) + FileManager.default.createFile(atPath: releaseAlternateURL.path, contents: Data()) + + let finished = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + finished.signal() + } + XCTAssertEqual(finished.wait(timeout: .now() + 5), .success) + wait(for: [serverClosed], timeout: 5) + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + XCTAssertEqual(process.terminationStatus, 0, stderr) + XCTAssertTrue(stdout.contains("OK surface=surface-id pane=pane-id"), stdout) + XCTAssertTrue(FileManager.default.fileExists(atPath: diffStartedURL.path)) + + let openingURL = try XCTUnwrap(openedURLBox.get()) + let htmlURL = try resolvedDiffViewerHTMLFileURL(openingHTMLURL, from: ["url": openingURL]) + let html = try String(contentsOf: htmlURL, encoding: .utf8) + let patch = try String(contentsOf: htmlURL.deletingPathExtension().appendingPathExtension("patch"), encoding: .utf8) + XCTAssertFalse(html.contains("data-cmux-diff-pending=\"true\""), html) + XCTAssertTrue(html.contains("Slow diff"), html) + XCTAssertTrue(patch.contains("+new line"), patch) + } + func testTopCommandSortsWorkspacesByCPUDescending() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("top-cpu") @@ -1856,7 +2123,11 @@ final class CMUXOpenCommandTests: XCTestCase { XCTAssertEqual(viewerURL.fragment, "cmux-diff-viewer") XCTAssertNil(params["diff_viewer_token"]) XCTAssertNil(params["diff_viewer_files"]) - let viewerFileURL = try diffViewerHTMLFileURL(for: rawURL, from: params) + let openedFileURL = try diffViewerHTMLFileURL(for: rawURL, from: params) + let viewerFileURL = try resolvedDiffViewerHTMLFileURL(openedFileURL, from: params) + if openedFileURL != viewerFileURL { + defer { try? FileManager.default.removeItem(at: openedFileURL) } + } defer { try? FileManager.default.removeItem(at: viewerFileURL) } let html = try String(contentsOf: viewerFileURL, encoding: .utf8) let patchURL = viewerFileURL.deletingPathExtension().appendingPathExtension("patch") @@ -1870,11 +2141,62 @@ final class CMUXOpenCommandTests: XCTestCase { return (html, patch, params, result.stdout) } + private func resolvedDiffViewerHTMLFileURL(_ fileURL: URL, from params: [String: Any]) throws -> URL { + var current = fileURL + for _ in 0..<4 { + let html = try String(contentsOf: current, encoding: .utf8) + guard let redirectURL = Self.diffViewerRedirectURL(from: html) else { + return current + } + current = try diffViewerHTMLFileURL(for: redirectURL, from: params) + } + return current + } + + private static func diffViewerRedirectURL(from html: String) -> String? { + let marker = "data-cmux-diff-redirect=\"" + guard let start = html.range(of: marker)?.upperBound else { return nil } + let tail = html[start...] + guard let end = tail.firstIndex(of: "\"") else { return nil } + return String(tail[.. URL { let rawURL = try XCTUnwrap(params["url"] as? String) return try diffViewerHTMLFileURL(for: rawURL, from: params) } + private static func diffViewerHTMLFileURLFromHTTPManifest(for rawURL: String) -> URL? { + guard let viewerURL = URL(string: rawURL), + viewerURL.scheme == "http", + viewerURL.host == "127.0.0.1" else { + return nil + } + let requestPath = URLComponents(url: viewerURL, resolvingAgainstBaseURL: false)?.percentEncodedPath ?? viewerURL.path + let pathParts = requestPath.split(separator: "/", omittingEmptySubsequences: true) + guard let token = pathParts.first.map(String.init), + !token.isEmpty else { + return nil + } + let manifestRequestPath = "/" + pathParts.dropFirst().joined(separator: "/") + let manifestURL = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("cmux-diff-viewer-\(Darwin.getuid())", isDirectory: true) + .appendingPathComponent(".manifest-\(token).json", isDirectory: false) + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let files = manifest["files"] as? [[String: Any]], + let entry = files.first(where: { file in + file["request_path"] as? String == manifestRequestPath && + file["mime_type"] as? String == "text/html" + }), + let filePath = entry["file_path"] as? String else { + return nil + } + return URL(fileURLWithPath: filePath, isDirectory: false) + } + private func diffViewerHTMLFileURL(for rawURL: String, from params: [String: Any]) throws -> URL { let viewerURL = try XCTUnwrap(URL(string: rawURL)) if viewerURL.scheme == "http" { @@ -2211,6 +2533,17 @@ final class CMUXOpenCommandTests: XCTestCase { } } + private func waitUntil(timeout: TimeInterval, condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + } + return condition() + } + private func bindUnixSocket(at path: String) throws -> Int32 { unlink(path) diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index b78a901663..0e8538d8b0 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -128,6 +128,11 @@ function localizedText(text: LocalizedText, locale: string) { return locale.startsWith("ja") ? text.ja : text.en; } +function shortcutToConfig(shortcut: { combos: string[][]; configValue?: string }) { + if (shortcut.configValue) return shortcut.configValue; + return shortcutComboToConfig(shortcut.combos[0] ?? []); +} + function shortcutComboToConfig(combo: string[]) { const modifierMap: Record = { "⌘": "cmd", @@ -425,7 +430,7 @@ working-directory = ~/code`}
Default file value
- {shortcutComboToConfig(shortcut.combos[0] ?? [])} + {shortcutToConfig(shortcut)}
))} diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 8ea5222536..589befda4d 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -8,6 +8,7 @@ export type Shortcut = { combos: string[][]; description: LocalizedText; note?: LocalizedText; + configValue?: string; }; export type ShortcutCategory = { @@ -188,6 +189,43 @@ export const shortcutCategories: ShortcutCategory[] = [ }, ], }, + { + id: "diff-viewer", + titleKey: "diffViewer", + shortcuts: [ + { + id: "diffViewerScrollDown", + combos: [["J"]], + description: { en: "Scroll diff down", ja: "差分を下にスクロール" }, + note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, + }, + { + id: "diffViewerScrollUp", + combos: [["K"]], + description: { en: "Scroll diff up", ja: "差分を上にスクロール" }, + note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, + }, + { + id: "diffViewerScrollToBottom", + combos: [["⇧", "G"]], + description: { en: "Scroll diff to bottom", ja: "差分の末尾へスクロール" }, + note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, + }, + { + id: "diffViewerScrollToTop", + combos: [["G", "G"]], + description: { en: "Scroll diff to top", ja: "差分の先頭へスクロール" }, + note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, + configValue: '["g", "g"]', + }, + { + id: "diffViewerOpenFileSearch", + combos: [["/"]], + description: { en: "Open diff file search", ja: "差分ファイル検索を開く" }, + note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, + }, + ], + }, { id: "find", titleKey: "find", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index bc139c7cc9..7891119da2 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1085,9 +1085,31 @@ "useSelectionForFind", "toggleBrowserDeveloperTools", "showBrowserJavaScriptConsole", - "toggleReactGrab" + "toggleReactGrab", + "diffViewerScrollDown", + "diffViewerScrollUp", + "diffViewerScrollToBottom", + "diffViewerScrollToTop", + "diffViewerOpenFileSearch" ] }, + "properties": { + "diffViewerScrollDown": { + "$ref": "#/$defs/diffViewerShortcutBindingNullable" + }, + "diffViewerScrollUp": { + "$ref": "#/$defs/diffViewerShortcutBindingNullable" + }, + "diffViewerScrollToBottom": { + "$ref": "#/$defs/diffViewerShortcutBindingNullable" + }, + "diffViewerScrollToTop": { + "$ref": "#/$defs/diffViewerShortcutBindingNullable" + }, + "diffViewerOpenFileSearch": { + "$ref": "#/$defs/diffViewerShortcutBindingNullable" + } + }, "additionalProperties": { "$ref": "#/$defs/shortcutBindingNullable" } @@ -1148,6 +1170,43 @@ } ] }, + "diffViewerShortcutBinding": { + "oneOf": [ + { + "$ref": "#/$defs/unboundShortcutBinding", + "description": "Unbind this shortcut. Accepted values are an empty string, none, clear, unbound, or disabled." + }, + { + "$ref": "#/$defs/diffViewerShortcutFirstStroke", + "description": "Single-stroke diff viewer shortcut, for example j, k, /, or shift+g." + }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "prefixItems": [ + { + "$ref": "#/$defs/diffViewerShortcutFirstStroke" + }, + { + "$ref": "#/$defs/shortcutStroke" + } + ], + "description": "Chorded diff viewer shortcut. Example: [\"g\", \"g\"]." + } + ] + }, + "diffViewerShortcutBindingNullable": { + "oneOf": [ + { + "$ref": "#/$defs/diffViewerShortcutBinding" + }, + { + "type": "null", + "description": "Unbind this shortcut." + } + ] + }, "unboundShortcutBinding": { "type": "string", "enum": ["", "none", "clear", "unbound", "disabled"] @@ -1170,6 +1229,27 @@ ], "description": "First or only shortcut stroke. Must include a modifier unless the key is Space." }, + "diffViewerShortcutFirstStroke": { + "allOf": [ + { + "$ref": "#/$defs/shortcutStroke" + }, + { + "anyOf": [ + { + "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)+" + }, + { + "pattern": "^(?: |[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>)$" + }, + { + "pattern": "^(?:[A-Za-z0-9]|[,./\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT])$" + } + ] + } + ], + "description": "First or only diff viewer shortcut stroke. Bare printable keys are accepted." + }, "shortcutStroke": { "type": "string", "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)*(?: |[A-Za-z0-9]|[lL][eE][fF][tT]|[aA][rR][rR][oO][wW][lL][eE][fF][tT]|[lL][eE][fF][tT][aA][rR][rR][oO][wW]|←|[rR][iI][gG][hH][tT]|[aA][rR][rR][oO][wW][rR][iI][gG][hH][tT]|[rR][iI][gG][hH][tT][aA][rR][rR][oO][wW]|→|[uU][pP]|[aA][rR][rR][oO][wW][uU][pP]|[uU][pP][aA][rR][rR][oO][wW]|↑|[dD][oO][wW][nN]|[aA][rR][rR][oO][wW][dD][oO][wW][nN]|[dD][oO][wW][nN][aA][rR][rR][oO][wW]|↓|[tT][aA][bB]|[rR][eE][tT][uU][rR][nN]|[eE][nN][tT][eE][rR]|↩|[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>|[,./\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT]|[fF](?:[1-9]|1[0-9]|20)|[vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][uU][pP]|[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][uU][tT][eE]|[mM][eE][dD][iI][aA][mM][uU][tT][eE]|[mM][eE][dD][iI][aA]\\.[mM][uU][tT][eE]|[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA][pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA]\\.[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK])$", diff --git a/web/messages/ar.json b/web/messages/ar.json index 6f1127e39e..9a5995f625 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -635,7 +635,8 @@ "notifications": "الإشعارات", "find": "البحث", "terminal": "الطرفية", - "window": "النافذة" + "window": "النافذة", + "diffViewer": "عارض الاختلافات" }, "sc": { "ws-new": "مساحة عمل جديدة", diff --git a/web/messages/bs.json b/web/messages/bs.json index 0f007d5798..ad0b68e6bc 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -635,7 +635,8 @@ "notifications": "Notifikacije", "find": "Pretraga", "terminal": "Terminal", - "window": "Prozor" + "window": "Prozor", + "diffViewer": "Preglednik diffa" }, "sc": { "ws-new": "Novi radni prostor", diff --git a/web/messages/da.json b/web/messages/da.json index 2824ad6cb2..3970517f13 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -635,7 +635,8 @@ "notifications": "Notifikationer", "find": "Find", "terminal": "Terminal", - "window": "Vindue" + "window": "Vindue", + "diffViewer": "Diff-viser" }, "sc": { "ws-new": "Nyt workspace", diff --git a/web/messages/de.json b/web/messages/de.json index 6ca17ee79c..028b13d659 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -635,7 +635,8 @@ "notifications": "Benachrichtigungen", "find": "Suche", "terminal": "Terminal", - "window": "Fenster" + "window": "Fenster", + "diffViewer": "Diff-Viewer" }, "sc": { "ws-new": "Neuer Workspace", diff --git a/web/messages/en.json b/web/messages/en.json index 4b4eccaeb3..950b261954 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -745,7 +745,8 @@ "notifications": "Notifications", "find": "Find", "terminal": "Terminal", - "window": "Window" + "window": "Window", + "diffViewer": "Diff Viewer" }, "sc": { "ws-new": "New workspace", diff --git a/web/messages/es.json b/web/messages/es.json index 38edbf861a..b3305052ee 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -635,7 +635,8 @@ "notifications": "Notificaciones", "find": "Búsqueda", "terminal": "Terminal", - "window": "Ventana" + "window": "Ventana", + "diffViewer": "Visor de diffs" }, "sc": { "ws-new": "Nuevo workspace", diff --git a/web/messages/fr.json b/web/messages/fr.json index e5d98eb7d0..10b2edb1b8 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -635,7 +635,8 @@ "notifications": "Notifications", "find": "Recherche", "terminal": "Terminal", - "window": "Fenêtre" + "window": "Fenêtre", + "diffViewer": "Visionneuse de diff" }, "sc": { "ws-new": "Nouvel espace de travail", diff --git a/web/messages/it.json b/web/messages/it.json index 5e9cd9e456..28a566d872 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -635,7 +635,8 @@ "notifications": "Notifiche", "find": "Ricerca", "terminal": "Terminale", - "window": "Finestra" + "window": "Finestra", + "diffViewer": "Visualizzatore diff" }, "sc": { "ws-new": "Nuovo workspace", diff --git a/web/messages/ja.json b/web/messages/ja.json index 22b30ad6fb..5ffdd1db4f 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -703,7 +703,8 @@ "notifications": "通知", "find": "検索", "terminal": "ターミナル", - "window": "ウィンドウ" + "window": "ウィンドウ", + "diffViewer": "差分ビューア" }, "sc": { "ws-new": "新規ワークスペース", diff --git a/web/messages/km.json b/web/messages/km.json index 9ae3c6b430..690f8ed8e9 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -635,7 +635,8 @@ "notifications": "ជូនដំណឹង", "find": "ស្វែងរក", "terminal": "ទែមីណល", - "window": "បង្អួច" + "window": "បង្អួច", + "diffViewer": "កម្មវិធីមើល diff" }, "sc": { "ws-new": "Workspace ថ្មី", diff --git a/web/messages/ko.json b/web/messages/ko.json index 6c1411cf65..8cee54f493 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -635,7 +635,8 @@ "notifications": "알림", "find": "찾기", "terminal": "터미널", - "window": "창" + "window": "창", + "diffViewer": "Diff 뷰어" }, "sc": { "ws-new": "새 워크스페이스", diff --git a/web/messages/no.json b/web/messages/no.json index 50189829d2..2f53c5d9a2 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -635,7 +635,8 @@ "notifications": "Varsler", "find": "Finn", "terminal": "Terminal", - "window": "Vindu" + "window": "Vindu", + "diffViewer": "Diff-viser" }, "sc": { "ws-new": "Nytt arbeidsområde", diff --git a/web/messages/pl.json b/web/messages/pl.json index e83e10ecec..715c792c5c 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -635,7 +635,8 @@ "notifications": "Powiadomienia", "find": "Znajdź", "terminal": "Terminal", - "window": "Okno" + "window": "Okno", + "diffViewer": "Podgląd diff" }, "sc": { "ws-new": "Nowy workspace", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index bffc87d79f..ed24f40f56 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -635,7 +635,8 @@ "notifications": "Notificações", "find": "Buscar", "terminal": "Terminal", - "window": "Janela" + "window": "Janela", + "diffViewer": "Visualizador de diffs" }, "sc": { "ws-new": "Novo workspace", diff --git a/web/messages/ru.json b/web/messages/ru.json index 95bc0e96bc..8cefd73354 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -635,7 +635,8 @@ "notifications": "Уведомления", "find": "Поиск", "terminal": "Терминал", - "window": "Окно" + "window": "Окно", + "diffViewer": "Просмотр diff" }, "sc": { "ws-new": "Новое рабочее пространство", diff --git a/web/messages/th.json b/web/messages/th.json index 53d0065145..f9c598c76f 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -635,7 +635,8 @@ "notifications": "การแจ้งเตือน", "find": "ค้นหา", "terminal": "เทอร์มินัล", - "window": "หน้าต่าง" + "window": "หน้าต่าง", + "diffViewer": "ตัวดู diff" }, "sc": { "ws-new": "Workspace ใหม่", diff --git a/web/messages/tr.json b/web/messages/tr.json index 13dd051b18..eb15cd0c89 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -635,7 +635,8 @@ "notifications": "Bildirimler", "find": "Bul", "terminal": "Terminal", - "window": "Pencere" + "window": "Pencere", + "diffViewer": "Diff görüntüleyici" }, "sc": { "ws-new": "Yeni çalışma alanı", diff --git a/web/messages/uk.json b/web/messages/uk.json index 84e8e3efac..c8244191a7 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -636,7 +636,8 @@ "notifications": "Сповіщення", "find": "Пошук", "terminal": "Термінал", - "window": "Вікно" + "window": "Вікно", + "diffViewer": "Перегляд diff" }, "sc": { "ws-new": "Новий робочий простір", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index cb7a075525..a4368e537c 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -635,7 +635,8 @@ "notifications": "通知", "find": "查找", "terminal": "终端", - "window": "窗口" + "window": "窗口", + "diffViewer": "差异查看器" }, "sc": { "ws-new": "新建工作区", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 40bec8bc63..70b801db17 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -635,7 +635,8 @@ "notifications": "通知", "find": "搜尋", "terminal": "終端機", - "window": "視窗" + "window": "視窗", + "diffViewer": "差異檢視器" }, "sc": { "ws-new": "新增工作區",