Skip to content
9 changes: 9 additions & 0 deletions QuickMD/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to QuickMD will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **Reload** (⌘R, View ▸ Reload, or the ↻ button in the top-right cluster): re-reads the document from disk now and restarts the file watcher, for the times auto-reload does not notice a change (some network volumes and editors' save strategies). A toast says whether anything changed, or that the file could not be read; a file that is gone shows the "File no longer exists" banner, which clears by itself when the file comes back. Off while Source Edit shows, where the editor already follows the disk. Contributed by [@rinh](https://github.com/rinh) (#37).

### Fixed
- Full screen and Split View: opening another file adds a tab in place. Before, the tab group moved to a full-screen Space of its own, leaving the Split View broken, half the screen black and no menu bar. A full-screen window size is also no longer remembered as the size for new windows. Contributed by [@onemannoplan](https://github.com/onemannoplan) (#36).
- The Support pill (GitHub build) no longer keeps a blue focus ring after a click when keyboard navigation is on in System Settings.

## [1.12.0] - 2026-10-05

Source Edit: QuickMD stays a viewer first, but you can now fix the Markdown you are reading without leaving the window. Asked for by [@KillSwitchSIG](https://github.com/KillSwitchSIG) (#31) and [@vinaymve](https://github.com/vinaymve) (#34).
Expand Down
6 changes: 6 additions & 0 deletions QuickMD/QuickMD.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@
A1B2C3D401000140 /* EditCloseGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; };
A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; };
A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000141 /* EditCloseGuardTests.swift */; };
A1B2C3D401000190 /* WindowTabbing.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000003F /* WindowTabbing.swift */; };
A1B2C3D401000191 /* WindowTabbingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000190 /* WindowTabbingTests.swift */; };
A1B2C3D401000160 /* SourceEditSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; };
A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; };
A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000161 /* SourceEditSessionTests.swift */; };
Expand Down Expand Up @@ -180,6 +182,7 @@
A1B2C3D400000152 /* SourceEditorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorControllerTests.swift; sourceTree = "<group>"; };
A1B2C3D400000140 /* EditCloseGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuard.swift; sourceTree = "<group>"; };
A1B2C3D400000141 /* EditCloseGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuardTests.swift; sourceTree = "<group>"; };
A1B2C3D400000190 /* WindowTabbingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTabbingTests.swift; sourceTree = "<group>"; };
A1B2C3D400000160 /* SourceEditSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSession.swift; sourceTree = "<group>"; };
A1B2C3D400000161 /* SourceEditSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSessionTests.swift; sourceTree = "<group>"; };
A1B2C3D400000180 /* SourceEditLanding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditLanding.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -318,6 +321,7 @@
A1B2C3D400000152 /* SourceEditorControllerTests.swift */,
A1B2C3D400000171 /* SourceTintTests.swift */,
A1B2C3D400000141 /* EditCloseGuardTests.swift */,
A1B2C3D400000190 /* WindowTabbingTests.swift */,
A1B2C3D400000161 /* SourceEditSessionTests.swift */,
A1B2C3D400000181 /* SourceEditLandingTests.swift */,
);
Expand Down Expand Up @@ -694,6 +698,8 @@
A1B2C3D401000172 /* SourceTintTests.swift in Sources */,
A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */,
A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */,
A1B2C3D401000190 /* WindowTabbing.swift in Sources (Tests) */,
A1B2C3D401000191 /* WindowTabbingTests.swift in Sources */,
A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */,
A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */,
A1B2C3D401000181 /* SourceEditLanding.swift in Sources (Tests) */,
Expand Down
9 changes: 9 additions & 0 deletions QuickMD/QuickMD/MarkdownExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ struct FocusedOpenInExternalEditorKey: FocusedValueKey {
typealias Value = () -> Void
}

/// View ▸ Reload (⌘R) and the reload pill.
struct FocusedReloadDocumentKey: FocusedValueKey {
typealias Value = () -> Void
}

struct FocusedZoomActionKey: FocusedValueKey {
typealias Value = (MarkdownZoom) -> Void
}
Expand Down Expand Up @@ -147,6 +152,10 @@ extension FocusedValues {
get { self[FocusedOpenInExternalEditorKey.self] }
set { self[FocusedOpenInExternalEditorKey.self] = newValue }
}
var reloadDocumentAction: (() -> Void)? {
get { self[FocusedReloadDocumentKey.self] }
set { self[FocusedReloadDocumentKey.self] = newValue }
}
var zoomAction: ((MarkdownZoom) -> Void)? {
get { self[FocusedZoomActionKey.self] }
set { self[FocusedZoomActionKey.self] = newValue }
Expand Down
54 changes: 40 additions & 14 deletions QuickMD/QuickMD/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,9 @@ struct MarkdownView: View {

/// The top-right pill cluster.
///
/// Reading: zoom reset (while zoomed), Edit (Source Edit, ⌥⌘E), Open in
/// editor (⌘E), Copy source — gone in Reading Mode, whose shortcuts (⌘0,
/// ⌥⌘E, ⌘E, ⌘⇧C) all still work, so nothing is lost except the thing
/// Reading: zoom reset (while zoomed), Reload (⌘R), Edit (Source Edit,
/// ⌥⌘E), Copy source — gone in Reading Mode, whose shortcuts (⌘0, ⌘R,
/// ⌥⌘E, ⌘⇧C) all still work, so nothing is lost except the thing
/// hovering over the text.
///
/// Editing: ONLY Save and Done, also in Reading Mode — a mode you can only
Expand Down Expand Up @@ -434,6 +434,9 @@ struct MarkdownView: View {
.transition(.opacity)
}
if documentURL != nil {
ReloadDocumentButton(theme: theme) {
reloadManually()
}
EditSourceButton(theme: theme) {
toggleSourceEdit()
}
Expand Down Expand Up @@ -503,12 +506,8 @@ struct MarkdownView: View {
}
.background(theme.backgroundColor)
.background(WindowConfigurator { window in
// Make every QuickMD document window prefer to join existing windows
// as tabs (rather than as standalone windows), regardless of the
// system-wide "Prefer tabs" preference. All windows share one
// tabbingIdentifier so AppKit groups them in a single tabbed window.
window.tabbingMode = .preferred
window.tabbingIdentifier = "pl.falami.studio.QuickMD.Document"
// Tabbing is set up app-wide (WindowTabbing.preferTabsAlways); the
// window keeps SwiftUI's shared tabbingIdentifier.
// Remembered so the process-global key monitor below can ignore
// key events addressed to OTHER windows (tabs are separate windows).
hostWindow = window
Expand All @@ -526,6 +525,7 @@ struct MarkdownView: View {
})
.focusedSceneValue(\.copyDocumentAction, { copyToClipboard(currentText) })
.focusedSceneValue(\.openInExternalEditorAction, { openInExternalEditor() })
.focusedSceneValue(\.reloadDocumentAction, { reloadManually() })
.focusedSceneValue(\.toggleDocumentListAction, {
guard !isReadingMode else { return }
withAnimation(.easeInOut(duration: 0.2)) { isDocumentListVisible.toggle() }
Expand Down Expand Up @@ -818,10 +818,14 @@ struct MarkdownView: View {
fileWatcher = watcher
}

/// What `reloadFromDisk` found — the ⌘R toast reports it.
private enum DiskReload { case changed, unchanged, unreadable, ownedBySession }

/// Re-reads the watched file and swaps the displayed text if it changed.
/// Same decode + line-ending normalization as the initial document load.
private func reloadFromDisk() {
guard let url = documentURL else { return }
@discardableResult
private func reloadFromDisk() -> DiskReload {
guard let url = documentURL else { return .unreadable }
if editSession.isActive {
// The session reads the disk itself and keeps the rendered text
// equal to it through `commitText` (S-D9) — or shows the banner
Expand All @@ -831,17 +835,39 @@ struct MarkdownView: View {
if fileMissing && FileManager.default.fileExists(atPath: url.path) {
withAnimation(.easeInOut(duration: 0.2)) { fileMissing = false }
}
return
return .ownedBySession
}
// Only the read can fail: decoding falls back to Latin-1.
guard let data = try? Data(contentsOf: url),
let decoded = MarkdownDocument.decode(data) else { return }
let decoded = MarkdownDocument.decode(data) else { return .unreadable }
let text = MarkdownDocument.normalizeLineEndings(decoded)
if text != currentText {
let changed = text != currentText
if changed {
currentText = text
}
if fileMissing {
withAnimation(.easeInOut(duration: 0.2)) { fileMissing = false }
}
return changed ? .changed : .unchanged
}

/// ⌘R / the reload pill: re-read the file now, and restart the watcher in
/// case it lost track of the file (some volumes and save strategies never
/// deliver an event). Not while Source Edit shows — the session owns the
/// disk then.
private func reloadManually() {
guard !editSession.isActive, let url = documentURL else { return }
// Watch first, then read: a write landing in between is caught by the
// new watcher (at worst one more reload that finds nothing new). For a
// missing file the watcher shows the banner and polls for its return.
startWatching(url)
guard FileManager.default.fileExists(atPath: url.path) else { return }
switch reloadFromDisk() {
case .changed: showToast("Reloaded")
case .unchanged: showToast("Reloaded \u{00B7} no changes")
case .unreadable: showToast("Couldn't read the file")
case .ownedBySession: break
}
}

/// ⌘E — hand the document off to the user's configured editor, at the line
Expand Down
27 changes: 23 additions & 4 deletions QuickMD/QuickMD/QuickMDApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ struct QuickMDApp: App {

init() {
// Premium UX: every QuickMD document opens as a tab in an existing window
// (instead of stacking standalone windows). Overrides the system-wide
// "Prefer tabs when opening documents" preference for our app specifically.
// The actual tabbingMode + tabbingIdentifier is set per-window via
// WindowAccessor in MarkdownView.
// (instead of stacking standalone windows), also in full screen and
// Split View. Overrides the system-wide "Prefer tabs when opening
// documents" preference for our app only; AppKit does the tabbing as
// each window is ordered in. Must run before the first window exists.
NSWindow.allowsAutomaticWindowTabbing = true
WindowTabbing.preferTabsAlways()
}

var body: some Scene {
Expand All @@ -48,6 +49,8 @@ struct QuickMDApp: App {
CopyMarkdownCommand()
}
CommandGroup(after: .toolbar) {
ReloadDocumentCommand()
Divider()
ZoomCommands()
ToggleToCCommand()
ToggleDocumentListCommand()
Expand Down Expand Up @@ -209,6 +212,22 @@ struct CopyMarkdownCommand: View {
}
}

/// View ▸ Reload (⌘R) — re-reads the frontmost document from disk, for the
/// times auto-reload did not notice a change (network volumes, some editors'
/// save strategies). Off while Source Edit shows: the session owns the disk.
struct ReloadDocumentCommand: View {
@FocusedValue(\.reloadDocumentAction) var reloadDocumentAction
@FocusedValue(\.sourceEditState) var sourceEditState

var body: some View {
Button("Reload") {
reloadDocumentAction?()
}
.keyboardShortcut("r", modifiers: .command)
.disabled(reloadDocumentAction == nil || sourceEditState?.isEditing == true)
}
}

struct OpenInExternalEditorCommand: View {
@FocusedValue(\.openInExternalEditorAction) var openInExternalEditorAction
@FocusedValue(\.sourceEditState) var sourceEditState
Expand Down
20 changes: 20 additions & 0 deletions QuickMD/QuickMD/Views/ChromeButtons.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,22 @@ struct CopySourceButton: View {
}
}

// MARK: - Reload Button

/// Top-right button: re-read the document from disk (⌘R).
struct ReloadDocumentButton: View {
let theme: MarkdownTheme
let action: () -> Void

var body: some View {
ChromePill(theme: theme, title: "Reload", help: "Reload from disk (⌘R)", action: action) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11))
}
.accessibilityIdentifier("reload-document")
}
}

// MARK: - Edit Source Button

/// Top-right pencil button — fixes the source in place (Source Edit, ⌥⌘E).
Expand Down Expand Up @@ -438,6 +454,10 @@ struct SupportButton: View {
.environment(\.chromeExpansionSource, .fixed)
}
.menuStyle(.button)
// Like ChromePill: with keyboard navigation on (System Settings ▸
// Keyboard), a clicked pop-up button keeps focus and AppKit draws a
// blue ring around the pill over the document.
.focusable(false)
.opacity(hover.isExpanded ? 1.0 : 0.5)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: hover.isExpanded)
.modifier(ChromeHoverTracking(hover: $hover))
Expand Down
Loading
Loading