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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ Notable user-facing changes to PullMark. Release notes for GitHub releases are
extracted from this file by `scripts/make-release.sh` — keep the `## Unreleased`
section current as features land.

## Unreleased

- **PullMark speaks seven languages.** The entire app — menus, settings,
alerts, the sidebar, and every rendered-page control — is now
available in 中文, 日本語, Français, Deutsch, Nederlands, Español, and
Português, alongside English. PullMark follows your system language
automatically, or pick one in Settings → General → Language (each
language names itself) and relaunch with one click. Dates, counts,
and relative times follow your language's own conventions.

## 0.40.0 - 2026-08-20

- **Pasted GitHub screenshots finally render.** Images attached to PR
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ build:

test:
swift test $(TEST_FLAGS)
python3 scripts/check-strings.py

app:
./scripts/make-app.sh
Expand Down
13 changes: 5 additions & 8 deletions Sources/PullMark/App/AppLinkRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,11 @@ enum AppLinkRouter {
let version = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
let alert = NSAlert()
alert.messageText = "That link needs a different version of PullMark"
alert.informativeText = "This version (\(version)) doesn't know "
+ "\(url.absoluteString) — it may point at a feature from a newer "
+ "release, or one that has moved. Checking for updates usually "
+ "resolves it."
alert.addButton(withTitle: "Check for Updates…")
alert.addButton(withTitle: "Report an Issue…")
alert.addButton(withTitle: "Close")
alert.messageText = String(localized: "That link needs a different version of PullMark")
alert.informativeText = String(localized: "This version (\(version)) doesn't know \(url.absoluteString) — it may point at a feature from a newer release, or one that has moved. Checking for updates usually resolves it.")
alert.addButton(withTitle: String(localized: "Check for Updates…"))
alert.addButton(withTitle: String(localized: "Report an Issue…"))
alert.addButton(withTitle: String(localized: "Close"))
NSApp.activate(ignoringOtherApps: true)
switch alert.runModal() {
case .alertFirstButtonReturn:
Expand Down
16 changes: 16 additions & 0 deletions Sources/PullMark/App/AppRelaunch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import AppKit

/// Quit-and-reopen for settings that only apply at launch (today: the
/// language override). A detached shell outlives the app, waits out
/// the termination, and opens the bundle fresh — the standard trick,
/// since `open` against a still-running instance would just focus it.
enum AppRelaunch {
static func relaunch() {
let path = Bundle.main.bundleURL.path
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/sh")
task.arguments = ["-c", "sleep 0.6; /usr/bin/open \"$0\"", path]
try? task.run()
NSApp.terminate(nil)
}
}
26 changes: 13 additions & 13 deletions Sources/PullMark/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -951,7 +951,7 @@ final class AppState: ObservableObject {
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = true
panel.message = "Open Markdown files or a folder containing them"
panel.message = String(localized: "Open Markdown files or a folder containing them")
guard panel.runModal() == .OK else { return }
for url in panel.urls { add(url: url) }
}
Expand All @@ -962,7 +962,7 @@ final class AppState: ObservableObject {
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.message = "Open Markdown files"
panel.message = String(localized: "Open Markdown files")
guard panel.runModal() == .OK else { return }
for url in panel.urls { add(url: url) }
}
Expand All @@ -972,7 +972,7 @@ final class AppState: ObservableObject {
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.message = "Open a folder containing Markdown files"
panel.message = String(localized: "Open a folder containing Markdown files")
guard panel.runModal() == .OK else { return }
for url in panel.urls { add(url: url) }
}
Expand Down Expand Up @@ -1507,7 +1507,7 @@ final class AppState: ObservableObject {
folders[index].truncated = truncated
folders[index].git = git
if initialScan, filePaths.isEmpty {
lastNotice = "No Markdown files found in \(root.lastPathComponent)."
lastNotice = String(localized: "No Markdown files found in \(root.lastPathComponent).")
}
} else {
// The root vanished (unmounted volume, deleted
Expand Down Expand Up @@ -2004,10 +2004,9 @@ final class AppState: ObservableObject {
/// client already appends a sign-in hint when no credentials resolved).
static func remoteFailureMessage(_ error: Error, what: String) -> String {
if let api = error as? GitHubClient.APIError, api.status == 404 {
return "Couldn't open \(what): \(api.message). It may not exist at that ref, "
+ "or it may be a private repository your GitHub credentials can't access."
return String(localized: "Couldn't open \(what): \(api.message). It may not exist at that ref, or it may be a private repository your GitHub credentials can't access.")
}
return "Couldn't open \(what): \(error.localizedDescription)"
return String(localized: "Couldn't open \(what): \(error.localizedDescription)")
}

/// The 60s quiet tick for the frontmost PR (spec: pr-cockpit):
Expand Down Expand Up @@ -2161,7 +2160,7 @@ final class AppState: ObservableObject {
// queue through the stale index (code-review catch).
await refreshCockpit(sessionID: sessionID)
} catch {
lastError = "Could not refresh \(session.id): \(error.localizedDescription)"
lastError = String(localized: "Could not refresh \(session.id): \(error.localizedDescription)")
}
}

Expand Down Expand Up @@ -2381,7 +2380,7 @@ final class AppState: ObservableObject {
guard let serverID = comment.serverID else {
// Landed by the atomic create, id not echoed back yet (see
// stateAfterCreate) — a server-side delete needs the id.
lastError = "This comment is still syncing with GitHub — try discarding it again in a moment."
lastError = String(localized: "This comment is still syncing with GitHub — try discarding it again in a moment.")
return
}
let ref = prSessions[index].ref
Expand All @@ -2390,7 +2389,7 @@ final class AppState: ObservableObject {
try await client.deleteReviewComment(ref, commentID: serverID)
await adoptPendingReview(sessionID: sessionID)
} catch {
lastError = "Could not discard the pending comment: \(error.localizedDescription)"
lastError = String(localized: "Could not discard the pending comment: \(error.localizedDescription)")
}
}
}
Expand Down Expand Up @@ -2563,8 +2562,9 @@ final class AppState: ObservableObject {
let count = prSessions.first(where: { $0.id == sessionID })?
.queuedComments.count ?? 0
if count > 0 {
lastError = "Could not upload \(count) pending comment\(count == 1 ? "" : "s") "
+ "to GitHub — kept locally for retry. \(error.localizedDescription)"
lastError = count == 1
? String(localized: "Could not upload 1 pending comment to GitHub — kept locally for retry. \(error.localizedDescription)")
: String(localized: "Could not upload \(count) pending comments to GitHub — kept locally for retry. \(error.localizedDescription)")
}
return
}
Expand Down Expand Up @@ -2679,7 +2679,7 @@ final class AppState: ObservableObject {
}
clearPendingState(sessionID: sessionID)
} catch {
lastError = "Could not abandon the review: \(error.localizedDescription)"
lastError = String(localized: "Could not abandon the review: \(error.localizedDescription)")
}
}

Expand Down
22 changes: 10 additions & 12 deletions Sources/PullMark/App/DMGGreeter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,10 @@ enum DMGGreeter {

private static func offerMove(_ image: DiskImages.MountedImage) {
let alert = NSAlert()
alert.messageText = "Move PullMark to your Applications folder?"
alert.informativeText = "PullMark is running from its disk image. "
+ "Moving it to Applications installs it properly and enables one-click updates."
alert.addButton(withTitle: "Move to Applications")
alert.addButton(withTitle: "Not Now")
alert.messageText = String(localized: "Move PullMark to your Applications folder?")
alert.informativeText = String(localized: "PullMark is running from its disk image. Moving it to Applications installs it properly and enables one-click updates.")
alert.addButton(withTitle: String(localized: "Move to Applications"))
alert.addButton(withTitle: String(localized: "Not Now"))
guard alert.runModal() == .alertFirstButtonReturn else { return }

let destination = "/Applications/PullMark.app"
Expand All @@ -48,8 +47,8 @@ enum DMGGreeter {
try fm.copyItem(atPath: Bundle.main.bundlePath, toPath: destination)
} catch {
let failure = NSAlert()
failure.messageText = "Couldn't move PullMark"
failure.informativeText = "Drag PullMark to Applications in the Finder instead. (\(error.localizedDescription))"
failure.messageText = String(localized: "Couldn't move PullMark")
failure.informativeText = String(localized: "Drag PullMark to Applications in the Finder instead. (\(error.localizedDescription))")
failure.runModal()
return
}
Expand All @@ -67,11 +66,10 @@ enum DMGGreeter {
private static func offerCleanup(_ image: DiskImages.MountedImage) {
let file = (image.imagePath as NSString).lastPathComponent
let alert = NSAlert()
alert.messageText = "Remove the PullMark disk image?"
alert.informativeText = "PullMark is installed — the disk image is no longer needed. "
+ "This ejects it and moves “\(file)” to the Trash."
alert.addButton(withTitle: "Move to Trash")
alert.addButton(withTitle: "Keep")
alert.messageText = String(localized: "Remove the PullMark disk image?")
alert.informativeText = String(localized: "PullMark is installed — the disk image is no longer needed. This ejects it and moves “\(file)” to the Trash.")
alert.addButton(withTitle: String(localized: "Move to Trash"))
alert.addButton(withTitle: String(localized: "Keep"))
guard alert.runModal() == .alertFirstButtonReturn else {
var declined = UserDefaults.pullmark.stringArray(forKey: DefaultsKeys.dmgCleanupDeclined) ?? []
declined.append(image.imagePath)
Expand Down
6 changes: 3 additions & 3 deletions Sources/PullMark/App/DocumentExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ enum DocumentExport {
case .success(let data):
write(data, to: url, onError: onError)
case .failure(let error):
onError("Could not create the PDF: \(error.localizedDescription)")
onError(String(localized: "Could not create the PDF: \(error.localizedDescription)"))
}
}
}
Expand All @@ -27,7 +27,7 @@ enum DocumentExport {
else { return }
document.proxy.pageDOM { dom in
guard let dom else {
onError("Could not read the rendered page.")
onError(String(localized: "Could not read the rendered page."))
return
}
let html = selfContainedHTML(dom: dom, document: document)
Expand Down Expand Up @@ -117,7 +117,7 @@ enum DocumentExport {
do {
try data.write(to: url)
} catch {
onError("Could not save \(url.lastPathComponent): \(error.localizedDescription)")
onError(String(localized: "Could not save \(url.lastPathComponent): \(error.localizedDescription)"))
}
}
}
31 changes: 15 additions & 16 deletions Sources/PullMark/App/PullMarkApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ struct PullMarkApp: App {
.keyboardShortcut(shortcuts.keyboardShortcut(for: .prFlipLayout))
.disabled(!prFileSelected)
Button(state?.resolvedConversationsVisible == true
? "Hide Resolved Conversations" : "Show Resolved Conversations") {
? String(localized: "Hide Resolved Conversations") : String(localized: "Show Resolved Conversations")) {
state?.resolvedConversationsVisible.toggle()
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .showResolvedConversations))
Expand Down Expand Up @@ -183,7 +183,7 @@ struct PullMarkApp: App {
}

private var copyGitHubLinkAlternate: some View {
Button(githubLinkDefaultIsPermalink ? "Copy GitHub Branch Link" : "Copy GitHub Permalink") {
Button(githubLinkDefaultIsPermalink ? String(localized: "Copy GitHub Branch Link") : String(localized: "Copy GitHub Permalink")) {
copyGitHubLink(permalink: !githubLinkDefaultIsPermalink)
}
.disabled(selectionGitHubLinkURL == nil)
Expand Down Expand Up @@ -296,7 +296,7 @@ struct PullMarkApp: App {
if let root = LocalGit.repoRoot(for: url) {
state?.commitRequest = CommitRequest(root: root)
} else {
state?.lastNotice = "\(url.lastPathComponent) isn't inside a git repository."
state?.lastNotice = String(localized: "\(url.lastPathComponent) isn't inside a git repository.")
}
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .commitChanges))
Expand All @@ -306,9 +306,9 @@ struct PullMarkApp: App {
guard let url = activeLocalFileURL else { return }
do {
try EditHistory.revertLastEdit(for: url)
state?.lastNotice = "Reverted the last edit to \(url.lastPathComponent)."
state?.lastNotice = String(localized: "Reverted the last edit to \(url.lastPathComponent).")
} catch {
state?.lastError = "Couldn't revert: \(error.localizedDescription)"
state?.lastError = String(localized: "Couldn't revert: \(error.localizedDescription)")
}
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .revertLastEdit))
Expand Down Expand Up @@ -346,8 +346,7 @@ struct PullMarkApp: App {
Button("Copy as Markdown") { copyAsMarkdown() }
.keyboardShortcut(shortcuts.keyboardShortcut(for: .copyAsMarkdown))
.disabled(state?.activeDocument == nil)
.help("Copies the Markdown source of the selected blocks "
+ "(whole blocks — or the whole document when nothing is selected)")
.help("Copies the Markdown source of the selected blocks (whole blocks — or the whole document when nothing is selected)")
}
CommandGroup(after: .textEditing) {
Button("Find in Page") { state?.findBarVisible = true }
Expand Down Expand Up @@ -390,8 +389,7 @@ struct PullMarkApp: App {
updates.historyMarkdown = history
updates.showHistory = true
} else {
state.lastNotice = "Release notes couldn't be loaded — "
+ "they're also at github.com/jedijashwa/pullmark/releases."
state.lastNotice = String(localized: "Release notes couldn't be loaded — they're also at github.com/jedijashwa/pullmark/releases.")
}
}
}
Expand Down Expand Up @@ -436,13 +434,13 @@ struct PullMarkApp: App {
}
}
Divider()
Button(state?.sourceViewVisible == true ? "Hide Markdown Source" : "Show Markdown Source") {
Button(state?.sourceViewVisible == true ? String(localized: "Hide Markdown Source") : String(localized: "Show Markdown Source")) {
state?.sourceViewVisible.toggle()
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .toggleSource))
.disabled(state?.activeDocument == nil)
.help("Temporarily show the raw Markdown behind the rendered document")
Button(outlineVisible ? "Hide Outline" : "Show Outline") {
Button(outlineVisible ? String(localized: "Hide Outline") : String(localized: "Show Outline")) {
outlineVisible.toggle()
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .toggleOutline))
Expand All @@ -457,15 +455,13 @@ struct PullMarkApp: App {
}
.disabled(activeLocalFileURL == nil
|| state?.expectedSurfaceToolbar?.compareGitAvailable != true)
.help("What changed since the last commit, rendered like a PR "
+ "diff — the toolbar's Compare button offers older "
+ "revisions and branches")
Button(marginNotesVisible ? "Hide Margin Notes" : "Show Margin Notes") {
.help("What changed since the last commit, rendered like a PR diff — the toolbar's Compare button offers older revisions and branches")
Button(marginNotesVisible ? String(localized: "Hide Margin Notes") : String(localized: "Show Margin Notes")) {
marginNotesVisible.toggle()
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .toggleMarginNotes))
.help("Margin-note bubbles (<!-- note --> comments) in rendered documents")
Button(showHiddenFiles ? "Hide Hidden Files" : "Show Hidden Files") {
Button(showHiddenFiles ? String(localized: "Hide Hidden Files") : String(localized: "Show Hidden Files")) {
showHiddenFiles.toggle()
}
.keyboardShortcut(shortcuts.keyboardShortcut(for: .toggleHiddenFiles))
Expand Down Expand Up @@ -586,6 +582,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
Appearance.applyCurrent()
// Pin the launch language before any UI (or the user) can
// change the stored value — the Settings row compares against it.
_ = AppLanguage.atLaunch
let cliURLs = LaunchArguments.consumeFileURLs()
if !cliURLs.isEmpty {
OpenURLRouter.shared.deliver(cliURLs)
Expand Down
2 changes: 1 addition & 1 deletion Sources/PullMark/App/ShortcutStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ final class ShortcutStore: ObservableObject {
case .notBindable:
return "Shortcuts need ⌘ or ⌃ (function keys can stand alone)."
case .reserved(let combo, let command):
return "\(combo.display) is reserved for \(command)."
return String(localized: "\(combo.display) is reserved for \(command).")
case .taken(let combo, let action):
return "\(combo.display) is already used by “\(action.title)”."
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/PullMark/App/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ final class UpdateChecker: ObservableObject {
Task { @MainActor in
if let message = await checkManually() {
let alert = NSAlert()
alert.messageText = "Check for Updates"
alert.messageText = String(localized: "Check for Updates")
alert.informativeText = message
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
Expand Down
Loading
Loading