Two commits plus review fixes. DocumentFlowCoordinator.activateSession now tears down the outgoing session's macOS image and diagram windows — guarded by a document-identity comparison so reopening the same document keeps windows the incoming session simply adopts.
diagramOwnerURL), so "equal" means "adopted, still closable", never "stranded".diagramOwnerURL synthesises a per-session prism://clipboard/{id}, so two pastes are always a replacement.session.reloadContent(…) and never reach activateSession.make build-macos and make build-ios both succeed, SwiftLint reports 0 violations across 542 files, the six DocumentFlowCoordinator suites run 37/37 green, and the live-WebKit suites that a crash cascade knocked out mid-run pass 129/129 when re-run in isolation — all counts read from the result bundle.Ready to push
The fix is minimal, correctly placed, and pinned in both directions by tests that fail without it. activateSession is genuinely the single chokepoint — currentSession is assigned in exactly two places in the whole app (closeDocument → nil, and here), and all six creation paths funnel through it. Ownership is keyed exclusively on diagramOwnerURL, which is the same key the guard compares, so an equal comparison provably means the incoming session can still reach (and later close) those windows — a wrong skip cannot produce an unreachable window. A wrong teardown (path variants like /tmp vs /private/tmp) is the safe direction.
Local validation on macOS and iOS is green; the review raised no blockers and no majors within scope. One pre-existing orphan path (DocumentSession.didSave re-keys ownership under the user's feet) was found nearby and is recommended as a separate ticket, not a change to this PR.
3b9ba8e Fix T-1757: Replacing a document leaves macOS media windows orphaned 8af6346 Fix T-1757 review: reopening the same document is not a replacement 9df03ba Fix T-1757 pre-push review: correct three claims about the fix On a Mac, Prism can open extra windows beside your document: a zoomed image, or a mermaid diagram. Those windows belong to the document you opened them from.
Until now, they were only cleaned up when you closed a document. If you instead opened a different document straight away — from the file picker, Recent Files, a paste, the bundled guides, or a URL — the old document's image and diagram windows stayed on screen, belonging to a document that was no longer open. Nothing could ever close them again except closing each one by hand.
This change closes them at the moment the document is replaced.
Stale windows are confusing: they show content from a document you are no longer reading, and Prism had lost its only handle on them.
Opening the document you are already reading (picking it again from Recent Files, re-dropping the same file, refetching the same URL) also builds a new session internally — but it is not really a replacement. If the code just closed windows whenever the session object changed, reopening your current document would destroy the diagram you had open beside it for no reason.
So the teardown only fires when the incoming document is a different document. Same document means the new session inherits the windows and can still close them later.
DocumentFlowCoordinator.activateSession(_:clearPersisted:cancelRemoteDownload:addToRecent:) is the shared installer behind every document-creation path: openFile, openRecentFile/bookmark open, openBundledDocument, pasteFromClipboard, openRemoteSession, and restorePersistedSession. It already centralised remote-download cancellation, persisted-state clearing, and scroll-position persistence for the outgoing session.
Media-window teardown was the one outgoing-session concern that had not been centralised — it lived only in closeDocument(). So any replacement that skipped an explicit close leaked windows. The fix moves that teardown into the chokepoint, macOS-only.
if let outgoingOwnerURL = currentSession?.diagramOwnerURL,
outgoingOwnerURL != session.diagramOwnerURL {
imageServices?.imageDetailWindowManager.closeWindows(ownedBy: outgoingOwnerURL)
diagramWindowManager?.closeWindows(ownedBy: outgoingOwnerURL)
}Both ImageDetailWindowManager and DiagramWindowManager keep a windowOwnership map keyed on DocumentSession.diagramOwnerURL, and closeWindows(ownedBy:) matches on exactly that value. The guard compares the same key rather than any looser notion of "same document" (the codebase's other identity abstraction, DocumentIdentifierResolver, normalises paths and would have been the wrong tool here — a looser match could report equal while the incoming session registers under a different key, which is the orphaning bug again).
Consequences of that choice, by source shape:
prism://clipboard/{sessionID}, so two pastes never compare equal and a paste is always a replacement.sessionID, so the synthetic URL is stable across a restore.URL equality is representation-based with no canonicalisation, so a path variant (/tmp vs /private/tmp, importer URL vs bookmark-resolved URL) compares unequal and takes the teardown branch. That fails closed: it may close windows the user would have kept, but never strands them — strictly no worse than the pre-fix behaviour.
Six new tests in DocumentFlowCoordinatorReplaceWindowCleanupTests drive the real activation entry points (not activateSession directly) and assert on manager state: replacement closes (file, clipboard, different remote URL), reopen keeps (same file, same remote URL), and an unrelated document's windows are untouched. The same-document tests were verified by mutation to fail without the guard.
currentSession has exactly two assignment sites in the application: closeDocument() (to nil) and activateSession. Every session constructor call site routes through the latter, and the failure paths (unreadable file, decode failure) return before activation — so a failed open correctly leaves the current document and its windows alone. That makes the placement complete rather than merely convenient.
The invariant that makes the guard safe is not "the documents are the same" but "the incoming session's windows will register, and be found, under the key we just decided not to clear". Since closeWindows(ownedBy:) matches windowOwnership values by == against diagramOwnerURL, comparing that exact value makes a skip self-justifying: equality implies reachability from the new session, so its eventual closeDocument() — or the next genuine replacement — still tears the windows down. Any normalised or heuristic identity (e.g. DocumentIdentifierResolver's notes-scoped DocumentIdentifier, which collapses bundled/{name} and resolves symlinks) could report equal for two sessions whose windows register under different keys, reintroducing the orphan. Conversely a false inequality only over-closes. The error surface is therefore asymmetric by construction, and asymmetric in the safe direction.
Teardown runs before currentSession = session — necessarily, since afterwards the outgoing key is gone. closeWindows snapshots identifiers by owner, calls NSWindow.close(), and lets the willClose observer unregister asynchronously (NotificationCenter on .main → Task { @MainActor }). No incoming-session window can be caught: registration happens only on user action via withHostingWindow, and the incoming session's views are not yet mounted.
DocumentSession.didSave(to:) mutates source from .clipboard to .file(url:) in place, flipping diagramOwnerURL from prism://clipboard/{id} to the file URL. Windows registered before the save keep the old key, and both closeDocument() and this new teardown look up only the current key — so those windows can never be closed. Repro: paste markdown with a diagram, open the diagram window, File > Export. This is the one genuine orphan path left in the area; it wants a reassignOwnership(from:to:) on both managers, as a separate ticket.openWindow(…) → withHostingWindow registration is asynchronous, so a window opened in the instant before a replace registers after the teardown, under the departing owner. Inherited from T-1504; closing it needs pending-open teardown too.@State while WindowGroup permits several document windows. If two windows show the same file and one opens a replacement, it closes the other's media windows. Previously only an explicit close did this; a replacement is far more frequent, so the exposure grows even though the design (URL-keyed ownership) is unchanged.byteOffset identity may no longer match any block — the same staleness the in-place reload path already carries, now reachable one more way. Acceptable and consistent.The full macOS suite run for this review ended in a WebContent-related SIGABRT in the test host (+[NSException raise:format:] under WebKit) that took ~194 queued tests with it — the known crash-cascade signature on this project, aggravated by concurrent sibling builds. The affected suites are all live-WebKit ones, none of which this change can reach; the DocumentFlowCoordinator suites completed green both in the full run and in an isolated 37/37 run read from the result bundle.
prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. This is the entire behaviour change. It closes the orphan path for all five replacement routes (file picker, Recent Files, paste, bundled, URL) by moving a teardown that previously existed only in closeDocument() into the single session-installation chokepoint.
What to look at. prism/ViewModels/DocumentFlowCoordinator.swift:679-711
prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. Without it, reopening the document you are already reading (Recent Files, re-dropping the same file, refetching the same URL) would destroy your open diagram and image windows — a regression introduced by the first commit and caught in review round 1.
What to look at. prism/ViewModels/DocumentFlowCoordinator.swift:706-707
prism/Models/DocumentSession.swift
Why it matters. It is the reason the guard is safe for documents with no backing file. Had diagramOwnerURL been optional or collapsed to a shared value, two successive pastes would compare equal and the second paste would inherit the first's stale windows.
What to look at. prism/Models/DocumentSession.swift:370-372 (pre-existing; relied on by the new guard)
prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift
Why it matters. The positive direction (replacement closes) and the negative direction (reopen keeps) are separate failure modes, and the second is the one that a naive fix gets wrong. Both are pinned, through the real entry points rather than by calling the private installer.
What to look at. prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift:77-322
CHANGELOG.md
Why it matters. It is the user-facing description of a macOS behaviour change that also documents the deliberate non-change (reopen and in-place reload keep their windows).
What to look at. CHANGELOG.md:94
The codebase has one identity abstraction, DocumentIdentifierResolver, but it is notes-scoped: it normalises symlinks and collapses bundled/{name}. Using it here would be actively wrong — it could report two sessions equal whose windows register under different keys, which is the orphaning bug. Window ownership is keyed on the exact diagramOwnerURL value, so that is the only comparison guaranteed consistent with the teardown.
Path variants (/tmp vs /private/tmp, importer URL vs bookmark-resolved URL) compare unequal and take the teardown branch. That is a deliberate fail-closed: over-closing is recoverable and matches the pre-fix closeDocument behaviour, while a false equality would leave windows unreachable forever. Canonicalising would trade a cosmetic annoyance for a correctness risk.
Necessary rather than stylistic: after the assignment the outgoing owner key is gone. It is also safe — closeWindows snapshots identifiers by owner, and the incoming session cannot yet have registered anything (registration happens on user action via withHostingWindow).
Reviewed and deliberately skipped. The pair now appears in closeDocument() and activateSession, and a closeMediaWindows(ownedBy:) helper would name the "both managers, always together" invariant. But the two sites differ in how they obtain the key (captured before nil-ing vs guarded on identity), the duplication is two lines, and refactoring closeDocument() widens a bugfix PR that has already passed two review rounds. Worth revisiting when a third media-window kind appears.
Unregistration is asynchronous by design (NotificationCenter on .main → Task { @MainActor }) and prismTests has no poll-until-drained helper, so a timed yield is the only mechanism available. 200 ms matches the sibling suite DocumentFlowCoordinatorDiagramCloseTests that covers the same drain; the nearer-in-time 50 ms precedent would shave ~0.9 s but buys nothing on a loaded machine.
The invariant is already recorded in the two places a future session reaches first — the comment block at the change site and the test-file header — and docs/agent-notes/scroll-persistence.md already records that closeDocument and activateSession are the two outgoing-session hooks. Adding a note would be the checkbox-at-task-end note the project rules warn against.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | CHANGELOG.md:94 — unsupported consequence | The entry claimed stranded windows were left "where their toolbars acted on nothing". MermaidDiagramWindow and ImageDetailWindow reference no session and no coordinator; their zoom / copy-source / share toolbars keep working after the owning document goes away. | Replaced with the consequence the code does support: the windows are left with no way to clear them short of closing each one by hand. |
| minor | CHANGELOG.md:94 — describes a bug that never shipped | "picking the current document again from Recent Files no longer costs you the diagram you had open beside it" implies a shipped regression. Before this branch activateSession did no teardown at all, so a reopen never closed windows; the guard preserves existing behaviour rather than restoring it. | Reworded to "still costs you nothing". |
| minor | DocumentFlowCoordinator.swift:704-705 — inaccurate comment clause | The comment called the teardown branch "the pre-existing, non-orphaning behaviour". Teardown was pre-existing in closeDocument(), but the pre-existing behaviour of this path was no teardown at all — that was the bug. | Reworded to state it fails closed into the teardown branch, the safe direction: it can close windows the user would rather have kept, but never strands any. |
| major | DocumentSession.didSave(to:) — pre-existing orphan path (out of scope) | didSave mutates source from .clipboard to .file(url:) in place, silently re-keying diagramOwnerURL while windows stay registered under the old prism://clipboard/{id} key. Both closeDocument() and the new teardown then look up the wrong key, so those windows can never be closed. Repro: paste markdown with a diagram, open the diagram window, File > Export. | Not changed here — it predates this branch and fixing it needs a reassignOwnership(from:to:) on both window managers. Recommended as a follow-up Transit ticket. |
| minor | DocumentFlowCoordinator.swift:553-560 / :706-710 — duplicated closeWindows pair | The "close image windows and diagram windows for this owner" pair now exists twice. It is a completeness invariant: a third media-window kind would have to be added in both places or the bug returns on one path. | Skipped deliberately — two lines, two sites that obtain the owner key differently, and extracting it would pull closeDocument() into a bugfix PR that has already cleared two review rounds. Noted for the next media-window addition. |
| nit | DocumentFlowCoordinator.swift:678-711 — comment-to-code ratio | 29 lines of comment for 4 lines of code, with the rationale restated again in the CHANGELOG entry and the test-file header. | Skipped. The file's prevailing style is heavily commented (handleOpenURL, handleSaveFailed), and each paragraph carries a distinct load-bearing fact: why the guard exists, why clipboard sessions cannot collide, and which direction the comparison fails in. |
| nit | prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift — 200 ms waits | Six 200 ms drain waits (~1.2 s). The nearest sibling covering the same drain, DocumentFlowCoordinatorImageWindowTests, uses 50 ms. | Skipped. DocumentFlowCoordinatorDiagramCloseTests — the suite this file most directly extends — uses 200 ms, and shortening a drain wait on a contended machine trades ~0.9 s for flakiness risk. |
| nit | prismTests — makeWindow / makeImageServices duplication | makeWindow() is hand-rolled in five test files; makeImageServices(_:) is a character-for-character copy of the one in DocumentFlowCoordinatorImageWindowTests and enumerates all eight ImageServices properties. | Skipped. Per-suite window factories are the established convention here; the ImageServices copy is worth consolidating into a shared test factory, but as a chore rather than inside this bugfix. |
Click to expand.
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swiftindex dfa9fbf..123af43 100644--- a/prism/ViewModels/DocumentFlowCoordinator.swift+++ b/prism/ViewModels/DocumentFlowCoordinator.swift@@ -676,6 +676,40 @@ final class DocumentFlowCoordinator { // not lose the position. No-op when there is no outgoing session or // for clipboard sessions. currentSession?.persistScrollPosition()+ #if os(macOS)+ // Close any image/diagram windows the outgoing session owns before it+ // is replaced. Every activation path (open, paste, bundled, remote)+ // routed through here previously skipped this teardown — only an+ // explicit closeDocument() performed it — so replacing a document left+ // its media windows on screen, now belonging to a document that is no+ // longer open (T-1757). Uses the same `diagramOwnerURL` key+ // closeDocument() uses so file and clipboard sessions are both covered.+ //+ // Skipped when the incoming session denotes the SAME document, because+ // a reopen is not a replacement. `diagramOwnerURL` is exactly the key+ // `closeWindows(ownedBy:)` matches against, so equality here means the+ // incoming session's windows register under the same owner — they are+ // adopted by the new session rather than orphaned, and its eventual+ // close still tears them down. Without this guard, reopening the+ // already-open document (Recent Files, re-dropping the same file)+ // would destroy the user's open diagram/image windows for no reason,+ // and would contradict the in-place reload paths (remote Refresh,+ // FileChangeObserver) which keep those windows across new content.+ //+ // The comparison is safe for sessions with no backing URL:+ // `diagramOwnerURL` is non-optional and synthesises a per-session+ // `prism://clipboard/{id}` for clipboard (and resource-less bundled)+ // sessions, so two such sessions never compare equal — pasting new+ // clipboard content is still treated as a replacement. Any identity+ // mismatch (e.g. `/tmp` vs `/private/tmp`) fails closed into the+ // teardown branch, which is the safe direction: it can close windows+ // the user would rather have kept, but never strands any.+ if let outgoingOwnerURL = currentSession?.diagramOwnerURL,+ outgoingOwnerURL != session.diagramOwnerURL {+ imageServices?.imageDetailWindowManager.closeWindows(ownedBy: outgoingOwnerURL)+ diagramWindowManager?.closeWindows(ownedBy: outgoingOwnerURL)+ }+ #endif currentSession = session navigationPath = NavigationPath() navigationPath.append(session.id)
diff --git a/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift b/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swiftnew file mode 100644index 0000000..904ce86--- /dev/null+++ b/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift@@ -0,0 +1,324 @@+//+// DocumentFlowCoordinatorReplaceWindowCleanupTests.swift+// prismTests+//+// T-1757: Replacing a document leaves macOS media windows orphaned.+//+// activateSession(...) — the shared installer behind openFile, pasteFromClipboard,+// openBundledDocument, and openRemoteSession — replaced `currentSession` without+// the image/diagram window teardown that closeDocument() performs for the+// outgoing session. Only an explicit close tore those windows down, so+// replacing a document (without going through requestClose/closeDocument+// first) left its media windows on screen, now belonging to a document that+// was no longer open.+//+// These tests exercise activation paths directly (bypassing the unsaved-content+// confirmation gate) to prove the outgoing session's image and diagram windows+// close when a new document is activated.+//+// They also pin the boundary of that teardown, which is document identity —+// `diagramOwnerURL`, the exact key `closeWindows(ownedBy:)` matches on — and+// not object identity:+// - Reopening the document that is ALREADY open (Recent Files, re-dropping the+// same file, refetching the same remote URL) installs a new session object+// but is not a replacement. Its windows register under the same owner, so+// they are adopted rather than orphaned and must survive.+// - A genuinely different document is a replacement and must close them.+// - Two sessions with no backing file (successive clipboard pastes) are NOT+// the same document: `diagramOwnerURL` synthesises a per-session URL rather+// than collapsing to a shared nil, so a paste still replaces.+//++#if os(macOS)+import AppKit+import Foundation+import Testing+@testable import prism++@Suite("DocumentFlowCoordinator replace-document window cleanup", .serialized)+@MainActor+struct DocumentFlowCoordinatorReplaceWindowCleanupTests {++ // MARK: - Helpers++ private func makeImageServices(_ manager: ImageDetailWindowManager) -> ImageServices {+ ImageServices(+ imageCache: ImageCache(),+ imageLoader: ImageLoader(),+ snapshotCache: SnapshotCache(),+ svgRenderer: SVGRenderer(),+ svgSourceLoader: SVGSourceLoader(),+ directoryAccessManager: DirectoryAccessManager(),+ diagramCache: DiagramCache(),+ imageDetailWindowManager: manager+ )+ }++ /// `isReleasedWhenClosed = false` prevents NSWindow's default auto-release+ /// on `close()` from over-releasing the local test reference.+ private func makeWindow() -> NSWindow {+ let window = NSWindow(+ contentRect: CGRect(x: 0, y: 0, width: 400, height: 300),+ styleMask: [.titled, .closable, .resizable],+ backing: .buffered,+ defer: true+ )+ window.isReleasedWhenClosed = false+ return window+ }++ private func writeTempMarkdownFile(named name: String) throws -> URL {+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)+ try "# Incoming".write(to: url, atomically: true, encoding: .utf8)+ return url+ }++ // MARK: - openFile replaces a file-owned session++ @Test("openFile closes image and diagram windows owned by the outgoing file session")+ func openFileClosesOutgoingFileSessionWindows() async throws {+ let imageManager = ImageDetailWindowManager()+ let diagramManager = DiagramWindowManager()+ let flow = DocumentFlowCoordinator()+ flow.setImageServicesForTests(makeImageServices(imageManager))+ flow.setDiagramWindowManager(diagramManager)++ let outgoingURL = URL(fileURLWithPath: "/tmp/outgoing-T1757-file.md")+ let outgoingSession = DocumentSession(url: outgoingURL, content: "# Outgoing\n\n")+ flow.currentSession = outgoingSession++ let imageIdentifier = ImageDetailIdentifier(documentURL: outgoingURL, imageSource: "img.png")+ let imageWindow = makeWindow()+ imageManager.registerWindow(imageWindow, for: imageIdentifier, ownedBy: outgoingSession.diagramOwnerURL)++ let diagramIdentifier = DiagramIdentifier(documentURL: outgoingURL, byteOffset: 10)+ let diagramWindow = makeWindow()+ diagramManager.registerWindow(diagramWindow, for: diagramIdentifier, ownedBy: outgoingSession.diagramOwnerURL)++ #expect(imageManager.openWindowCount == 1)+ #expect(diagramManager.openWindowCount == 1)++ let newURL = try writeTempMarkdownFile(named: "incoming-T1757-\(UUID().uuidString).md")+ defer { try? FileManager.default.removeItem(at: newURL) }++ flow.openFile(at: newURL)++ // close() schedules unregistration via the willClose observer on the+ // main queue; yield so the run loop drains before asserting.+ try await Task.sleep(nanoseconds: 200_000_000)++ #expect(imageManager.openWindowCount == 0,+ "Image windows owned by the replaced document must close when a new document is opened")+ #expect(diagramManager.openWindowCount == 0,+ "Diagram windows owned by the replaced document must close when a new document is opened")+ #expect(flow.currentSession?.source.url == newURL)+ }++ // MARK: - pasteFromClipboard replaces a clipboard-owned session++ @Test("pasteFromClipboard closes image and diagram windows owned by the outgoing clipboard session")+ func pasteFromClipboardClosesOutgoingClipboardSessionWindows() async throws {+ let imageManager = ImageDetailWindowManager()+ let diagramManager = DiagramWindowManager()+ let flow = DocumentFlowCoordinator()+ flow.setImageServicesForTests(makeImageServices(imageManager))+ flow.setDiagramWindowManager(diagramManager)++ // Outgoing session is itself a clipboard paste — source.url is nil, so+ // the fix must key off diagramOwnerURL (the synthetic+ // prism://clipboard/{id} URL), not source.url, to find its windows.+ //+ // This also pins the negative half of the same-document guard: BOTH+ // sessions here have a nil `source.url`, and they are still two+ // different documents. Because `diagramOwnerURL` synthesises a+ // per-session URL rather than collapsing to a shared nil, the guard+ // cannot mistake one paste for a reopen of the previous one.+ let outgoingSession = DocumentSession(clipboardContent: "# Outgoing pasted\n\n")+ flow.currentSession = outgoingSession+ let outgoingOwnerURL = outgoingSession.diagramOwnerURL+ #expect(outgoingSession.source.url == nil)++ let imageIdentifier = ImageDetailIdentifier(documentURL: nil, imageSource: "img.png")+ let imageWindow = makeWindow()+ imageManager.registerWindow(imageWindow, for: imageIdentifier, ownedBy: outgoingOwnerURL)++ let diagramIdentifier = DiagramIdentifier(documentURL: nil, byteOffset: 3)+ let diagramWindow = makeWindow()+ diagramManager.registerWindow(diagramWindow, for: diagramIdentifier, ownedBy: outgoingOwnerURL)++ #expect(imageManager.openWindowCount == 1)+ #expect(diagramManager.openWindowCount == 1)++ NSPasteboard.general.clearContents()+ NSPasteboard.general.setString("# New pasted content", forType: .string)++ flow.pasteFromClipboard()++ try await Task.sleep(nanoseconds: 200_000_000)++ #expect(imageManager.openWindowCount == 0,+ "Image windows owned by the replaced clipboard document must close on paste")+ #expect(diagramManager.openWindowCount == 0,+ "Diagram windows owned by the replaced clipboard document must close on paste")+ #expect(flow.currentSession?.source == .clipboard)+ }++ // MARK: - Reopening the SAME document is not a replacement++ @Test("openFile reopening the already-open document keeps its media windows open")+ func openFileReopeningSameDocumentKeepsItsMediaWindowsOpen() async throws {+ let imageManager = ImageDetailWindowManager()+ let diagramManager = DiagramWindowManager()+ let flow = DocumentFlowCoordinator()+ flow.setImageServicesForTests(makeImageServices(imageManager))+ flow.setDiagramWindowManager(diagramManager)++ // The file must exist on disk: openFile reads it, and it has to be the+ // very same URL the outgoing session was built from so that both+ // sessions resolve to the same `diagramOwnerURL`.+ let sharedURL = try writeTempMarkdownFile(named: "same-doc-T1757-\(UUID().uuidString).md")+ defer { try? FileManager.default.removeItem(at: sharedURL) }++ let outgoingSession = DocumentSession(url: sharedURL, content: "# Outgoing\n\n")+ flow.currentSession = outgoingSession++ let imageIdentifier = ImageDetailIdentifier(documentURL: sharedURL, imageSource: "img.png")+ let imageWindow = makeWindow()+ imageManager.registerWindow(imageWindow, for: imageIdentifier, ownedBy: outgoingSession.diagramOwnerURL)++ let diagramIdentifier = DiagramIdentifier(documentURL: sharedURL, byteOffset: 10)+ let diagramWindow = makeWindow()+ diagramManager.registerWindow(diagramWindow, for: diagramIdentifier, ownedBy: outgoingSession.diagramOwnerURL)++ #expect(imageManager.openWindowCount == 1)+ #expect(diagramManager.openWindowCount == 1)++ // Reopening the current document (Recent Files, re-dropping the file,+ // re-importing the same path) still builds a brand new session object.+ flow.openFile(at: sharedURL)++ try await Task.sleep(nanoseconds: 200_000_000)++ let incomingSession = try #require(flow.currentSession)+ #expect(incomingSession !== outgoingSession,+ "A reopen installs a new session object — the guard must be about document identity, not object identity")+ #expect(incomingSession.diagramOwnerURL == outgoingSession.diagramOwnerURL,+ "Same document ⇒ same owner key, so the windows are adopted by the new session rather than orphaned")++ #expect(imageManager.openWindowCount == 1,+ "Reopening the already-open document must not destroy its image windows — nothing was replaced")+ #expect(diagramManager.openWindowCount == 1,+ "Reopening the already-open document must not destroy its diagram windows — nothing was replaced")+ #expect(imageManager.isOpen(imageIdentifier))+ #expect(diagramManager.isOpen(diagramIdentifier))+ // The surviving windows are still reachable under the new session's key,+ // so its eventual close (or a real replacement) still tears them down.+ #expect(diagramManager.identifiers(ownedBy: incomingSession.diagramOwnerURL).contains(diagramIdentifier))++ imageWindow.close()+ diagramWindow.close()+ imageManager.clearAllTracking()+ diagramManager.clearAllTracking()+ }++ @Test("openRemoteSession reopening the same remote URL keeps its media windows open")+ func openRemoteSessionReopeningSameURLKeepsItsMediaWindowsOpen() async throws {+ let imageManager = ImageDetailWindowManager()+ let diagramManager = DiagramWindowManager()+ let flow = DocumentFlowCoordinator()+ flow.setImageServicesForTests(makeImageServices(imageManager))+ flow.setDiagramWindowManager(diagramManager)++ // Remote sessions key ownership on the DISPLAY URL, so a Recent Files+ // reopen of the same remote document must be recognised as the same+ // document even though the fetch happens again.+ let fetchURL = URL(string: "https://raw.githubusercontent.com/o/r/main/doc.md")!+ let displayURL = URL(string: "https://github.com/o/r/blob/main/doc.md")!++ let outgoingSession = DocumentSession(remoteURL: fetchURL, displayURL: displayURL, content: "# Outgoing remote")+ flow.currentSession = outgoingSession++ let diagramIdentifier = DiagramIdentifier(documentURL: displayURL, byteOffset: 7)+ let diagramWindow = makeWindow()+ diagramManager.registerWindow(diagramWindow, for: diagramIdentifier, ownedBy: outgoingSession.diagramOwnerURL)++ #expect(diagramManager.openWindowCount == 1)++ flow.openRemoteSession(fetchURL: fetchURL, displayURL: displayURL, content: "# Refetched remote")++ try await Task.sleep(nanoseconds: 200_000_000)++ #expect(diagramManager.openWindowCount == 1,+ "Reopening the same remote URL must not destroy its diagram windows")+ #expect(flow.currentSession?.diagramOwnerURL == displayURL)++ diagramWindow.close()+ diagramManager.clearAllTracking()+ }++ @Test("openRemoteSession replacing with a different remote URL still closes the outgoing windows")+ func openRemoteSessionReplacingWithDifferentURLClosesOutgoingWindows() async throws {+ let diagramManager = DiagramWindowManager()+ let flow = DocumentFlowCoordinator()+ flow.setImageServicesForTests(makeImageServices(ImageDetailWindowManager()))+ flow.setDiagramWindowManager(diagramManager)++ let outgoingDisplayURL = URL(string: "https://github.com/o/r/blob/main/outgoing.md")!+ let outgoingSession = DocumentSession(+ remoteURL: outgoingDisplayURL,+ displayURL: outgoingDisplayURL,+ content: "# Outgoing remote"+ )+ flow.currentSession = outgoingSession++ let diagramIdentifier = DiagramIdentifier(documentURL: outgoingDisplayURL, byteOffset: 7)+ let diagramWindow = makeWindow()+ diagramManager.registerWindow(diagramWindow, for: diagramIdentifier, ownedBy: outgoingSession.diagramOwnerURL)++ #expect(diagramManager.openWindowCount == 1)++ let incomingDisplayURL = URL(string: "https://github.com/o/r/blob/main/incoming.md")!+ flow.openRemoteSession(+ fetchURL: incomingDisplayURL,+ displayURL: incomingDisplayURL,+ content: "# Incoming remote"+ )++ try await Task.sleep(nanoseconds: 200_000_000)++ #expect(diagramManager.openWindowCount == 0,+ "A genuinely different remote document is a replacement — the outgoing windows must close")+ }++ // MARK: - Windows owned by an unrelated document are untouched++ @Test("openFile leaves windows owned by an unrelated document open")+ func openFileLeavesUnrelatedDocumentWindowsOpen() async throws {+ let imageManager = ImageDetailWindowManager()+ let diagramManager = DiagramWindowManager()+ let flow = DocumentFlowCoordinator()+ flow.setImageServicesForTests(makeImageServices(imageManager))+ flow.setDiagramWindowManager(diagramManager)++ let currentURL = URL(fileURLWithPath: "/tmp/current-T1757.md")+ flow.currentSession = DocumentSession(url: currentURL, content: "# Current")++ let otherURL = URL(fileURLWithPath: "/tmp/other-T1757.md")+ let otherIdentifier = DiagramIdentifier(documentURL: otherURL, byteOffset: 1)+ let otherWindow = makeWindow()+ diagramManager.registerWindow(otherWindow, for: otherIdentifier, ownedBy: otherURL)++ let newURL = try writeTempMarkdownFile(named: "incoming-other-T1757-\(UUID().uuidString).md")+ defer { try? FileManager.default.removeItem(at: newURL) }++ flow.openFile(at: newURL)++ try await Task.sleep(nanoseconds: 200_000_000)++ #expect(diagramManager.isOpen(otherIdentifier))+ #expect(diagramManager.identifiers(ownedBy: otherURL).contains(otherIdentifier))++ diagramManager.clearAllTracking()+ }+}+#endif
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex e2ecf43..9609547 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -20,7 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- A note imported from a document, anchored to a nested list item (a sub-item under a top-level list item), now shows its quoted text and the section it belongs to (T-1871). In the notes pane it appeared with a blank quote and no heading, and it could not be told apart from any other nested-item note in the document. The note itself was always attached to the correct item — only the surrounding context was missing, and it was missing every time the document was opened, not just on a reload. Imported notes are rebuilt from the document on each open, and that rebuild only recognised top-level list-item identifiers, so a nested item's identifier was never matched and the context came back empty. Nested identifiers are now recognised the same way every other part of the app that addresses list items already recognises them, so an imported note on a nested item gets the same context as one on a top-level item. - A `prism://open?url=…` link now opens the address it names, even when that address mixes already-escaped and unescaped characters (T-2140). `…/my%20file and more.md` was fetched as `…/my%2520file%20and%20more.md` — a different resource, with no error shown — because the address had already been unescaped one layer by the time it was read, and was then escaped a second time in full. Investigating it surfaced a second fault of the same kind, live on every markdown link and image in every document: the escaping used for addresses turned an escaped `%2F` back into a real `/`, splitting one path segment into two. That silently broke any address that identifies something by an escaped path — a GitLab project URL, for instance, which 404s once `group%2Fproj` becomes `group/proj`. Escaped slashes and escaped ampersands now survive every route into the app: typed and pasted addresses, deep links, document links and images, `mailto:` links, and the GitHub blob-to-raw rewrite. One narrow side effect of reworking that rewrite: a GitHub address written with a doubled slash in it (`github.com//owner/repo/blob/…`) is no longer recognised as a file address, so it now reports an unsupported content type instead of opening. - In a document opened from a URL, an image or link whose query or anchor was already partly escaped no longer resolves to a corrupted address (T-1663). `/images/logo.png?token=a%20b c` was resolved as `?token=a%2520b%20c`, so the image failed to load and the link opened the wrong page: the already-escaped `%20` was escaped a second time because the whole query was treated as though none of it had been escaped yet. This affected both site-root addresses (starting with `/`) and the far more common document-relative form (`images/logo.png?token=a%20b c`); both are fixed. Query and anchor are now escaped the same way absolute URLs already were (T-1624) — an existing escape is left alone and only genuinely unescaped characters are encoded, so an escaped `%26` stays a literal character instead of decoding into a parameter separator and requesting a different resource.@@ -92,6 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - macOS: image detail windows opened from a pasted (clipboard) document now close when that document is closed, instead of being left open as orphaned windows. - Notes: rapidly reloading the same document no longer lets an older reload back up or persist notes state that a newer reload has already replaced. The load-generation guard is now re-checked after the backup step, before the state is saved. - Opening a file from the Recents list while a pasted (clipboard) document has unsaved content now shows the Unsaved Changes confirmation instead of silently replacing the unsaved document. Previously only bundled and URL-sourced recents were guarded; regular file recents skipped the dialog. Save and Discard both continue on to open the chosen recent file (T-1630).+- On the Mac, opening a document while another one is already open now closes the image and diagram windows belonging to the document you left, instead of stranding them (T-1757). Those windows were only ever cleared away by closing a document explicitly; opening a replacement — from the file picker, Recent Files, a paste, the bundled guides, or a URL — left them on screen belonging to a document that was no longer open, with no way left to clear them short of closing each one by hand. Reopening the document you are already reading is not a replacement and leaves its windows exactly where they are, so picking the current document again from Recent Files still costs you nothing; refreshing a URL document and reloading a file changed on disk keep those windows too, as they always have. Two pasted documents are never treated as the same document, so pasting fresh content still clears the previous paste's windows. - HTML comments embedded in paragraph text (and in headings, list items, and table cells) render again as dimmed inline annotations when "Show HTML comments" is on — they were silently dropped by the new rendering engine (T-1638). Stand-alone comments nested inside blockquotes or list items now get the same toggleable annotation treatment instead of leaking as always-visible plain text, and comment annotations regained their info indicator glyph. Search now stays aligned with what is actually rendered: comment shapes that are never displayed (conditional comments, note-infrastructure tags) no longer count as matches when the toggle is on, and hidden comment text nested inside blockquotes or list items no longer produces phantom highlights. ### Security
make test-quick reported 4210 passed / 196 failed, but the result bundle shows ~194 of those never ran: the test host took a SIGABRT (+[NSException raise:format:] under WebKit) partway through the live-WebKit suites and the rest were still queued. Every affected suite is a live-WebKit one (HTMLCommentVisibilityLiveTests, WebScrollNavigationTests, WebHiddenSectionGuardTests, WebDocumentBridgeLiveTests, WebPerfProbeLiveTests, WebSearchBridgeTests), none of which this macOS-only document-flow change can reach, and sibling agents were building concurrently. Re-running every affected suite in isolation confirms it: 129 passed, 0 failed, 13 skipped (read from the result bundle, not the exit code). The six DocumentFlowCoordinator suites ran green in the full session too, and 37/37 in their own isolated run.
The managers are App-level @State while WindowGroup permits several document windows. If two windows show the same file and one opens a replacement, it closes the other window's media windows. The design (URL-keyed ownership) is unchanged, but a replacement is far more frequent than an explicit close, so the exposure grows. Worth watching if multi-window reports appear.
Same-document identity is path-based, so a Recents reopen after an external edit keeps windows whose byteOffset identity may no longer match any block. This is the same staleness the in-place reload path already carries — consistent, and arguably the intended behaviour — but it is now reachable one more way.