diff --git a/README.md b/README.md index 4b4a7ea..1bd1190 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ coaches on macOS. Part of the DanceChess family. ## Features -- **Single-window workflow** — board + notation on top, your game list below. +- **One window per PGN, as tabs** — board + notation on top, that file's game list below; open files come back on launch. Arrow keys browse games and step through moves without ever touching the mouse; `Enter` dives into a game, `Esc` comes back. - **PGN is the source of truth** — open any .pgn and its games *are* the diff --git a/app/Studio/Database/GameListView.swift b/app/Studio/Database/GameListView.swift index e32386d..d4f3e2f 100644 --- a/app/Studio/Database/GameListView.swift +++ b/app/Studio/Database/GameListView.swift @@ -45,6 +45,9 @@ struct GameListView: NSViewRepresentable { let onDeleteRequest: ((Int64) -> Void)? /// Right-click with several rows selected → Merge Selected Games. var onMergeRequest: (([Int64]) -> Void)? = nil + /// The game to land on when the list first fills (the one last viewed + /// in this file); nil = row 0. + var initialSelection: Int64? = nil func makeCoordinator() -> Coordinator { Coordinator(view: self) } @@ -157,23 +160,22 @@ struct GameListView: NSViewRepresentable { /// Quiets delegate callbacks during programmatic re-selection. private var reselecting = false private var didInitialSelect = false - /// Startup-only: restore the last-viewed game once, then never again - /// (a replaced list must not inherit it). - private var pendingInitialId: Int64? = UserDefaults.standard - .object(forKey: DatabaseStore.lastSelectedGameKey) as? Int64 + /// Consumed once, when the list first fills. + private var pendingInitialId: Int64? init(view: GameListView) { self.view = view self.count = view.count self.revision = view.revision self.generation = view.generation + self.pendingInitialId = view.initialSelection } func resetForNewList() { selectedId = nil selectedRow = -1 didInitialSelect = false - pendingInitialId = nil // only the startup list restores it + pendingInitialId = view.initialSelection } func deselectQuietly() { diff --git a/app/Studio/Engine/OpeningTreePanel.swift b/app/Studio/Engine/OpeningTreePanel.swift index eff2a8b..cc3512b 100644 --- a/app/Studio/Engine/OpeningTreePanel.swift +++ b/app/Studio/Engine/OpeningTreePanel.swift @@ -9,6 +9,12 @@ import DanceChessCore @Observable @MainActor final class OpeningTreeModel { + /// The window's list: the local statistics and the games-reaching- + /// this-position filter are about this file. + let store: DatabaseStore + + init(store: DatabaseStore) { self.store = store } + private(set) var visible = false private(set) var rows: [TreeMove] = [] private(set) var source: ReferenceSource = AppSettings.shared.referenceSource @@ -49,7 +55,7 @@ final class OpeningTreeModel { errorText = nil openingName = nil noveltyText = nil - DatabaseStore.shared.setPositionFilter(nil) + store.setPositionFilter(nil) } func setSource(_ source: ReferenceSource) { @@ -72,7 +78,7 @@ final class OpeningTreeModel { self.fen = fen self.parentFen = parentFen self.san = san - DatabaseStore.shared.setPositionFilter(fen) + store.setPositionFilter(fen) generation += 1 let gen = generation pending?.cancel() @@ -82,9 +88,9 @@ final class OpeningTreeModel { loading = false errorText = nil openingName = nil - rows = (try? DatabaseStore.shared.db?.openingTree(fen: fen)) ?? [] + rows = (try? store.db?.openingTree(fen: fen)) ?? [] if let parentFen, let san, - let parentRows = try? DatabaseStore.shared.db?.openingTree(fen: parentFen) { + let parentRows = try? store.db?.openingTree(fen: parentFen) { noveltyText = Self.novelty(san: san, in: parentRows, total: parentRows.reduce(0) { $0 + $1.games }, source: source) @@ -174,10 +180,10 @@ struct OpeningTreePanel: View { @State private var showSettings = false private var statusText: String { - let local = "\(DatabaseStore.shared.matchedCount) in list" + let local = "\(tree.store.matchedCount) in list" switch tree.source { case .database: - return "\(DatabaseStore.shared.matchedCount) games reach this position" + return "\(tree.store.matchedCount) games reach this position" default: var parts = ["\(tree.onlineTotal) games"] if let name = tree.openingName { parts.append(name) } diff --git a/app/Studio/GameArea.swift b/app/Studio/GameArea.swift index 7273074..9b8234d 100644 --- a/app/Studio/GameArea.swift +++ b/app/Studio/GameArea.swift @@ -209,18 +209,18 @@ enum SavePrompt { static func run(for session: GameSession, canCancel: Bool) -> Bool { guard !suppressPrompts else { return true } let isNewGame = session.sourceGameId < 0 + guard let store = session.store, store.canWriteBack else { return true } if isNewGame { - // scratch entry: worth rescuing only if it has moves and a - // single-file list to land in - guard session.hasMoves, DatabaseStore.shared.canWriteBack else { return true } + // scratch entry: worth rescuing only if it has moves + guard session.hasMoves else { return true } } else { guard session.isModified else { return true } } let alert = NSAlert() alert.messageText = "Unsaved Changes" alert.informativeText = isNewGame - ? "Save this new game into “\(DatabaseStore.shared.sourceName ?? "the list")”?" - : "Save the changes to “\(session.gameTitle)” to the database?" + ? "Save this new game into “\(store.sourceName ?? "the list")”?" + : "Save the changes to “\(session.gameTitle)” to “\(store.sourceName ?? "the list")”?" alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Don't Save") if canCancel { alert.addButton(withTitle: "Cancel") } @@ -236,9 +236,12 @@ enum SavePrompt { @discardableResult static func save(_ session: GameSession) -> Bool { + guard let store = session.store else { + session.errorText = "This game's file is no longer open" + return false + } do { - try DatabaseStore.shared.updateGame(id: session.sourceGameId, - pgn: session.game.toPgn()) + try store.updateGame(id: session.sourceGameId, pgn: session.game.toPgn()) session.markSaved() return true } catch { @@ -250,8 +253,12 @@ enum SavePrompt { /// Appends a newly entered (scratch) game to the open list + PGN file. @discardableResult static func appendNewGame(_ session: GameSession) -> Bool { + guard let store = session.store else { + session.errorText = "No open file to save into" + return false + } do { - let id = try DatabaseStore.shared.addGame(pgn: session.game.toPgn()) + let id = try store.addGame(pgn: session.game.toPgn()) session.attachToDatabase(id: id) return true } catch { diff --git a/app/Studio/GameWindow.swift b/app/Studio/GameWindow.swift index c1fc7ce..4ac2dd3 100644 --- a/app/Studio/GameWindow.swift +++ b/app/Studio/GameWindow.swift @@ -8,8 +8,10 @@ import DanceChessCore /// The single-window flow lives in MainWindow; this one keeps its own /// Open/Paste PGN toolbar for scratch use. struct GameWindow: View { - /// A `games.id` from the default database, or -1 for a blank board. - var gameId: Int64 = -1 + /// Which file's game, or a blank board (`path` nil / `id` -1). Saving + /// needs that file's window to still be open; otherwise the board is + /// scratch. + var ref: GameRef @State private var session = GameSession() @State private var engine = EngineSession() @@ -33,8 +35,12 @@ struct GameWindow: View { }) .onAppear { keyMonitor.install { handleKey($0) } - if gameId >= 0, let pgn = DatabaseStore.shared.pgn(for: gameId) { - session.loadPgn(pgn, sourceId: gameId) + if let path = ref.path, + let store = OpenStores.shared.store(for: URL(fileURLWithPath: path)) { + session.store = store + if ref.id >= 0, let pgn = store.pgn(for: ref.id) { + session.loadPgn(pgn, sourceId: ref.id) + } } } .onDisappear { @@ -53,12 +59,12 @@ struct GameWindow: View { .toolbar { ToolbarItemGroup { Button("Save", systemImage: "square.and.arrow.down") { saveGame() } - .disabled(!DatabaseStore.shared.canWriteBack) + .disabled(!(session.store?.canWriteBack ?? false)) .help("Save this game into the open list and its PGN file (⌘S)") Button("Game Info", systemImage: "square.and.pencil") { showSaveSheet = true } - .disabled(!DatabaseStore.shared.canWriteBack) + .disabled(!(session.store?.canWriteBack ?? false)) .help("Edit the game's players, result, event… (⌘I)") Button("Open PGN", systemImage: "folder") { showImporter = true } .help("Load a PGN into this board (scratch, not the list)") @@ -92,7 +98,7 @@ struct GameWindow: View { } .sheet(isPresented: $showSaveSheet) { GameInfoSheet(session: session, - listName: DatabaseStore.shared.sourceName ?? "list") { + listName: session.store?.sourceName ?? "list") { if session.sourceGameId >= 0 { SavePrompt.save(session) } else { diff --git a/app/Studio/MacBaseApp.swift b/app/Studio/MacBaseApp.swift index d31cdc6..db4a599 100644 --- a/app/Studio/MacBaseApp.swift +++ b/app/Studio/MacBaseApp.swift @@ -1,12 +1,11 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers /// Per-window actions surfaced to the menu bar (nil = item disabled). /// Always-equal so SwiftUI doesn't churn on every body evaluation — the /// closures read live @State storage regardless. struct WindowActions: Equatable { - var openPgn: (() -> Void)? - var newPgnFile: (() -> Void)? var newGame: (() -> Void)? var save: (() -> Void)? var gameInfo: (() -> Void)? @@ -22,14 +21,9 @@ struct WindowActions: Equatable { var printGame: (() -> Void)? var exportPdf: (() -> Void)? var mergeGames: (() -> Void)? - var openRecent: ((String) -> Void)? - var clearRecents: (() -> Void)? - var recentFiles: [String] = [] - // recents are the only data the menu renders; closures read live state - static func == (lhs: WindowActions, rhs: WindowActions) -> Bool { - lhs.recentFiles == rhs.recentFiles - } + // nothing here is rendered; the closures read live state + static func == (lhs: WindowActions, rhs: WindowActions) -> Bool { true } } struct WindowActionsKey: FocusedValueKey { @@ -48,30 +42,29 @@ extension FocusedValues { /// per-window event monitor. struct StudioCommands: Commands { @FocusedValue(\.windowActions) private var actions + private var settings: AppSettings { AppSettings.shared } var body: some Commands { CommandGroup(replacing: .newItem) { Button("New Game") { actions?.newGame?() } .keyboardShortcut("n") .disabled(actions?.newGame == nil) - Button("New PGN File…") { actions?.newPgnFile?() } + Button("New PGN File…") { FileOpener.shared.createAndOpen() } .keyboardShortcut("n", modifiers: [.command, .shift]) - .disabled(actions?.newPgnFile == nil) - Button("Open PGN…") { actions?.openPgn?() } + Button("Open PGN…") { FileOpener.shared.chooseAndOpen() } .keyboardShortcut("o") - .disabled(actions?.openPgn == nil) Menu("Open Recent") { - ForEach(actions?.recentFiles ?? [], id: \.self) { path in + ForEach(settings.recentFiles, id: \.self) { path in Button((path as NSString).lastPathComponent) { - actions?.openRecent?(path) + FileOpener.shared.open(URL(fileURLWithPath: path)) } } - if !(actions?.recentFiles ?? []).isEmpty { + if !settings.recentFiles.isEmpty { Divider() - Button("Clear Menu") { actions?.clearRecents?() } + Button("Clear Menu") { settings.clearRecents() } } } - .disabled((actions?.recentFiles ?? []).isEmpty) + .disabled(settings.recentFiles.isEmpty) } // NB: `replacing: .saveItem` would be a silent no-op — a non-document // File menu has no Save group to replace — so append instead. @@ -137,16 +130,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) } - /// Finder / Dock hand-off (.pgn association, drag onto the Dock icon). + /// Finder / Dock hand-off (.pgn association, drag onto the Dock icon): + /// each file is a tab; one already open just comes forward. func application(_ application: NSApplication, open urls: [URL]) { MainActor.assumeIsolated { - let pgns = urls.filter { $0.pathExtension.lowercased() == "pgn" } - guard !pgns.isEmpty else { return } - // unsaved edits die with the replaced list — same gate as Open - for session in GameSession.SessionRegistry.shared.modified { - guard SavePrompt.run(for: session, canCancel: true) else { return } + for url in urls where url.pathExtension.lowercased() == "pgn" { + FileOpener.shared.open(url) } - DatabaseStore.shared.openPgn(pgns) } } @@ -154,6 +144,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// them; each window's own willClose saver is suppressed afterwards). func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { MainActor.assumeIsolated { + Self.trace("shouldTerminate") + // what is open now is what comes back next time; the windows + // closing after this must not edit the list + OpenStores.shared.freezeForQuit() let dirty = GameSession.SessionRegistry.shared.modified guard !dirty.isEmpty else { return .terminateNow } let alert = NSAlert() @@ -177,6 +171,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } } + + func applicationWillFinishLaunching(_ notification: Notification) { + Self.trace("willFinishLaunching") + // dev hook: a real quit (⌘Q path) after N seconds, so a test can + // exercise the restore list the way a user's session ends + if let secs = ProcessInfo.processInfo.environment["DCS_AUTO_QUIT"].flatMap(Double.init) { + Self.trace("auto-quit armed \(secs)s") + DispatchQueue.main.asyncAfter(deadline: .now() + secs) { + Self.trace("terminate requested") + NSApp.terminate(nil) + } + } + } + + func applicationWillTerminate(_ notification: Notification) { + Self.trace("willTerminate") + } + + /// DCS_TRACE=: lifecycle breadcrumbs (stdout dies with the process). + static func trace(_ what: String) { + guard let path = ProcessInfo.processInfo.environment["DCS_TRACE"] else { return } + if let h = FileHandle(forWritingAtPath: path) { + h.seekToEndOfFile(); h.write(Data((what + "\n").utf8)); h.closeFile() + } else { + try? (what + "\n").write(toFile: path, atomically: true, encoding: .utf8) + } + } } @main @@ -184,16 +205,92 @@ struct StudioApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { - // single main window: board + notation on top, game list below - WindowGroup { - MainWindow() + // one window per PGN file — board + notation on top, that file's + // game list below. Windows tab together (native macOS tabs); a file + // is opened once, and opening it again brings its window forward. + WindowGroup(for: URL.self) { $url in + MainWindow(url: $url) } .commands { StudioCommands() } - // standalone game windows (⌘-double-click / new game); -1 = blank board - WindowGroup(id: "game", for: Int64.self) { $gameId in - GameWindow(gameId: gameId) - } defaultValue: { - -1 + // standalone game windows (⌘-double-click): a game out of one of + // the open files, or a blank board + WindowGroup(id: "game", for: GameRef.self) { $ref in + GameWindow(ref: ref ?? GameRef(path: nil, id: -1)) + } + } +} + +/// A game in a file: what a standalone game window is opened with. +struct GameRef: Codable, Hashable { + /// Path of the file's window; nil = a scratch board with no file. + var path: String? + var id: Int64 +} + +/// Opens files as windows/tabs from places that have no SwiftUI +/// environment — the app delegate, the tab bar's "+", the standalone game +/// window. The first main window hands over `openWindow`; until then +/// requests queue and are replayed when it appears. +@MainActor +final class FileOpener { + static let shared = FileOpener() + private var opener: ((URL) -> Void)? + private var pending: [URL] = [] + /// Files a window has been assigned, whether or not its store has + /// loaded yet. The registry of stores is not enough: SwiftUI applies a + /// window's value on the next update pass, and two opens of one file + /// in the same turn would both see an empty registry and both open. + private var claimed: Set = [] + + func install(_ open: @escaping (URL) -> Void) { + opener = open + let queued = pending + pending = [] + for url in queued { self.open(url) } + } + + /// Marks `url` as taken by a window. False if it already was. + @discardableResult + func claim(_ url: URL) -> Bool { + claimed.insert(DatabaseStore.canonical(url)).inserted + } + + func release(_ url: URL) { + claimed.remove(DatabaseStore.canonical(url)) + } + + /// One file, one window: an open file's window comes forward instead. + func open(_ url: URL) { + let url = DatabaseStore.canonical(url) + if let existing = OpenStores.shared.store(for: url) { + existing.bringWindowForward() + return + } + guard claim(url) else { return } // a window is on its way to it + if let opener { opener(url) } else { pending.append(url) } + } + + func chooseAndOpen() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.init(filenameExtension: "pgn") ?? .plainText, .plainText] + panel.allowsMultipleSelection = true + panel.message = "Each file opens in its own tab." + guard panel.runModal() == .OK else { return } + for url in panel.urls { open(url) } + } + + func createAndOpen() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.init(filenameExtension: "pgn") ?? .plainText] + panel.nameFieldStringValue = "games.pgn" + panel.message = "Create a new, empty PGN file; it opens as a tab and games you enter are saved into it." + panel.prompt = "Create" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try DatabaseStore.createEmptyPgn(at: url) + open(url) + } catch { + NSAlert(error: error).runModal() } } } diff --git a/app/Studio/MainWindow.swift b/app/Studio/MainWindow.swift index 7be2aaa..5694828 100644 --- a/app/Studio/MainWindow.swift +++ b/app/Studio/MainWindow.swift @@ -17,17 +17,35 @@ import DanceChessCore /// Leaving a modified game prompts to save (ChessBase style; update_game /// writes back through Rust). struct MainWindow: View { - @State private var store = DatabaseStore.shared + /// The file this window shows. nil only for the window SwiftUI opens + /// at launch before anything is restored into it. + @Binding var url: URL? + @State private var store: DatabaseStore @State private var session = GameSession() @State private var engine = EngineSession() - @State private var tree = OpeningTreeModel() + @State private var tree: OpeningTreeModel + @State private var keyObservers: [NSObjectProtocol] = [] @State private var engaged = false + + init(url: Binding) { + _url = url + let store = DatabaseStore() + _store = State(initialValue: store) + _tree = State(initialValue: OpeningTreeModel(store: store)) + } + + /// Restoration runs once per launch, in whichever window appears first. + @MainActor private static var restored = false + /// This window arrived empty after launch — the tab bar's "+" (or + /// Window ▸ New Tab), which SwiftUI answers by opening the group's + /// scene with no value. Such a window asks for a file the moment it + /// has an NSWindow, and closes itself if none is chosen. + @State private var wantsFile = false @State private var listController = GameListController() @State private var keyMonitor = KeyEventMonitor() @State private var mouseMonitor = KeyEventMonitor() @State private var closeSaver = WindowCloseSaver() @State private var hostWindow: NSWindow? - @State private var showImporter = false @State private var showSaveSheet = false @State private var showSetupSheet = false @State private var showAnalyzeSheet = false @@ -67,7 +85,7 @@ struct MainWindow: View { onSelect: { loadSelected($0) }, onActivate: { id, commandKey in if commandKey { - openWindow(id: "game", value: id) + openWindow(id: "game", value: GameRef(path: store.sourceURL?.path, id: id)) } else if store.canWriteBack { // the row is already selected & loaded — edit it showSaveSheet = true @@ -76,7 +94,8 @@ struct MainWindow: View { } }, onDeleteRequest: { confirmAndDelete($0) }, - onMergeRequest: { mergeGames($0) } + onMergeRequest: { mergeGames($0) }, + initialSelection: store.lastSelectedGameId ) Divider() statusBar @@ -87,10 +106,26 @@ struct MainWindow: View { if hostWindow !== window { hostWindow = window closeSaver.attach(window: window, session: session) + store.window = window + attachWindow(window) + if wantsFile { askForFile() } } }) + .onChange(of: url, initial: true) { + if let url { store.load(url) } + } .onAppear { + session.store = store keyMonitor.install { handleKey($0) } + // any window can open more; the newest to appear holds the + // environment's openWindow + FileOpener.shared.install { openWindow(value: $0) } + let launchWindow = !Self.restored + restoreIfFirst() + if url == nil, !launchWindow { + wantsFile = true + if hostWindow != nil { askForFile() } + } // dev hook (like DCS_KEY_DEBUG): open the engine panel on // launch and jump to the game's end so smoke runs can screenshot // live analysis without pressing ⌘E (engine idles at the root) @@ -102,10 +137,46 @@ struct MainWindow: View { } } } - // dev hook: open a PGN on launch (exercises the cache path - // end-to-end without driving the file dialog) - if let path = ProcessInfo.processInfo.environment["DCS_AUTO_OPEN"] { - store.openPgn([URL(fileURLWithPath: path)]) + // dev hook: open PGN(s) on launch, comma-separated — the first + // into this window, the rest as further tabs + if launchWindow, url == nil, + let paths = ProcessInfo.processInfo.environment["DCS_AUTO_OPEN"] { + let urls = paths.split(separator: ",").map { URL(fileURLWithPath: String($0)) } + if let first = urls.first, FileOpener.shared.claim(first) { + url = DatabaseStore.canonical(first) + } + for more in urls.dropFirst() { FileOpener.shared.open(more) } + } + // dev hook: edit + save game 1 of THIS window's file (the save + // must touch this file and no other open one) + if let which = ProcessInfo.processInfo.environment["DCS_AUTO_TAB_SAVE"], + url?.lastPathComponent == which { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + guard let pgn = store.pgn(for: 1) else { return } + session.loadPgn(pgn, sourceId: 1) + session.toEnd() + session.applyNag(1) + SavePrompt.save(session) + } + } + // dev hook: the tab bar's "+", sent the way the button sends it + if ProcessInfo.processInfo.environment["DCS_AUTO_PLUS"] != nil, url != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + NSApp.sendAction(#selector(NSResponder.newWindowForTab(_:)), to: nil, from: nil) + } + } + // dev hook: report the windows/tabs and engine states + if let out = ProcessInfo.processInfo.environment["DCS_AUTO_TABS_OUT"], url != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + let windows = NSApp.windows.filter { $0.tabbingIdentifier == "dcstudio-database" && $0.isVisible } + var text = "windows: \(windows.count)\n" + for w in windows { + text += " \(w.title) tabbed=\(w.tabbedWindows?.count ?? 0) key=\(w.isKeyWindow)\n" + } + text += "open files: \(OpenStores.shared.all.compactMap { $0.sourceURL?.lastPathComponent })\n" + text += "restore list: \(AppSettings.shared.openFiles.map { ($0 as NSString).lastPathComponent })\n" + try? text.write(toFile: out, atomically: true, encoding: .utf8) + } } // dev hook: pop the game-info sheet (layout screenshots) if ProcessInfo.processInfo.environment["DCS_AUTO_INFO"] != nil { @@ -275,10 +346,8 @@ struct MainWindow: View { if engine.panelVisible { toggleEngine() } } } - .navigationTitle(store.sourceName.map { "DC Studio — \($0)" } ?? "DC Studio") + .navigationTitle(store.sourceName ?? "DC Studio") .focusedSceneValue(\.windowActions, WindowActions( - openPgn: { showImporter = true }, - newPgnFile: { newPgnFile() }, newGame: { newGame() }, save: { saveGame() }, gameInfo: { showSaveSheet = true }, @@ -293,31 +362,21 @@ struct MainWindow: View { insertDiagram: { session.toggleDiagram() }, printGame: { GamePrinter.print(session) }, exportPdf: { GamePrinter.exportPdf(session) }, - mergeGames: { mergeGames(listController.selectedGameIds) }, - openRecent: { path in - guard confirmLeaveGame() else { return } - store.openPgn([URL(fileURLWithPath: path)]) - }, - clearRecents: { store.clearRecents() }, - recentFiles: store.recentFiles + mergeGames: { mergeGames(listController.selectedGameIds) } )) - // drag a .pgn from Finder onto the window to open it + // drag .pgn files from Finder onto the window: each opens as a tab .dropDestination(for: URL.self) { urls, _ in let pgns = urls.filter { $0.pathExtension.lowercased() == "pgn" } - guard !pgns.isEmpty, confirmLeaveGame() else { return false } - store.openPgn(pgns) + guard !pgns.isEmpty else { return false } + for url in pgns { FileOpener.shared.open(url) } return true } .toolbar { ToolbarItemGroup { - Button("Open PGN", systemImage: "folder") { - showImporter = true - } + Button("Open PGN", systemImage: "folder") { FileOpener.shared.chooseAndOpen() } .disabled(store.importing) .help("Open a PGN file — its games replace the current list") - Button("New PGN", systemImage: "doc.badge.plus") { - newPgnFile() - } + Button("New PGN", systemImage: "doc.badge.plus") { FileOpener.shared.createAndOpen() } .disabled(store.importing) .help("Create a new, empty PGN file and open it as the list") Button("New Game", systemImage: "plus.square") { @@ -357,16 +416,14 @@ struct MainWindow: View { .help("Go to the end of the line (End)") } } - .fileImporter( - isPresented: $showImporter, - allowedContentTypes: [UTType(filenameExtension: "pgn") ?? .plainText, .plainText], - allowsMultipleSelection: true - ) { result in - if case .success(let urls) = result { - // unsaved edits die with the replaced list — offer to save - guard confirmLeaveGame() else { return } - store.openPgn(urls) - } + .onDisappear { + keyMonitor.remove() + mouseMonitor.remove() + closeSaver.detach() + for o in keyObservers { NotificationCenter.default.removeObserver(o) } + engine.shutdown() + OpenStores.shared.unregister(store) + if let url { FileOpener.shared.release(url) } } .frame(minWidth: 860, minHeight: 600) } @@ -534,8 +591,84 @@ struct MainWindow: View { guard id != session.sourceGameId else { return } if let pgn = store.pgn(for: id) { session.loadPgn(pgn, sourceId: id) - UserDefaults.standard.set(id, forKey: DatabaseStore.lastSelectedGameKey) + store.rememberSelected(id) + } + } + + /// Native tabs, and the engine yielding when the window is not key. + private func attachWindow(_ window: NSWindow?) { + guard let window else { return } + window.tabbingMode = .preferred + window.tabbingIdentifier = "dcstudio-database" + // SwiftUI has already shown the window on its own by the time we + // get it, so joining the tab group is done by hand: into whichever + // database window is in front (the restore sequence and ⌘O both + // land here) + if window.tabbedWindows == nil || window.tabbedWindows?.count == 1, + let anchor = NSApp.windows.first(where: { + $0 !== window && $0.isVisible && $0.tabbingIdentifier == "dcstudio-database" + }) { + anchor.addTabbedWindow(window, ordered: .above) + } + for o in keyObservers { NotificationCenter.default.removeObserver(o) } + let nc = NotificationCenter.default + keyObservers = [ + nc.addObserver(forName: NSWindow.didResignKeyNotification, object: window, + queue: .main) { _ in MainActor.assumeIsolated { engine.suspend() } }, + nc.addObserver(forName: NSWindow.didBecomeKeyNotification, object: window, + queue: .main) { _ in MainActor.assumeIsolated { engine.resume() } }, + ] + // the observers arrive a beat after the window does; a window that + // has already been pushed behind another by then must not keep + // searching as if it were in front + if !window.isKeyWindow { engine.suspend() } + } + + /// "+" opened this window empty: offer a file; nothing chosen, no window. + private func askForFile() { + wantsFile = false + AppDelegate.trace("empty window from +: asking for a file") + let chosen: [URL] + if ProcessInfo.processInfo.environment["DCS_AUTO_PLUS_CANCEL"] != nil { + chosen = [] // the test's Cancel + } else if let pick = ProcessInfo.processInfo.environment["DCS_AUTO_PLUS_PICK"] { + chosen = [URL(fileURLWithPath: pick)] // the test's Open + } else { + let panel = NSOpenPanel() + panel.allowedContentTypes = [UTType(filenameExtension: "pgn") ?? .plainText, .plainText] + panel.allowsMultipleSelection = true + panel.message = "Choose a PGN file for this tab." + chosen = panel.runModal() == .OK ? panel.urls : [] + } + guard let first = chosen.first else { + hostWindow?.close() + return + } + if let open = OpenStores.shared.store(for: first) { + // already open elsewhere: that tab comes forward, this one goes + open.bringWindowForward() + hostWindow?.close() + } else if FileOpener.shared.claim(first) { + url = DatabaseStore.canonical(first) + } else { + hostWindow?.close() } + for more in chosen.dropFirst() { FileOpener.shared.open(more) } + } + + /// The files open last time come back as tabs — the first into this + /// window (SwiftUI opened it empty), the rest through the opener. + private func restoreIfFirst() { + guard !Self.restored else { return } + Self.restored = true + guard url == nil, + ProcessInfo.processInfo.environment["DCS_AUTO_OPEN"] == nil else { return } + let files = AppSettings.shared.openFiles + .map { URL(fileURLWithPath: $0) } + .filter { FileManager.default.fileExists(atPath: $0.path) } + guard let first = files.first, FileOpener.shared.claim(first) else { return } + url = DatabaseStore.canonical(first) + for more in files.dropFirst() { FileOpener.shared.open(more) } } private func confirmLeaveGame() -> Bool { @@ -600,17 +733,6 @@ struct MainWindow: View { } } - private func newPgnFile() { - guard confirmLeaveGame() else { return } - let panel = NSSavePanel() - panel.allowedContentTypes = [UTType(filenameExtension: "pgn") ?? .plainText] - panel.nameFieldStringValue = "games.pgn" - panel.message = "Create a new, empty PGN file — it becomes the open list, and games you enter are saved into it." - panel.prompt = "Create" - guard panel.runModal() == .OK, let url = panel.url else { return } - store.createNewPgn(at: url) - } - // MARK: keyboard private func handleKey(_ event: NSEvent) -> Bool { diff --git a/app/Studio/Model/AppSettings.swift b/app/Studio/Model/AppSettings.swift index 97c7a2a..8a77517 100644 --- a/app/Studio/Model/AppSettings.swift +++ b/app/Studio/Model/AppSettings.swift @@ -55,6 +55,8 @@ final class AppSettings { private static let sourceKey = "referenceSource" private static let tokenAccount = "lichess-token" private static let figurinesKey = "figurineNotation" + private static let recentFilesKey = "recentFiles" + private static let openFilesKey = "openFiles" /// Where to make one: a read-only token is enough. static let tokenURL = URL(string: "https://lichess.org/account/oauth/token/create?description=DC+Studio+opening+explorer")! @@ -84,6 +86,27 @@ final class AppSettings { var figurines: Bool { didSet { UserDefaults.standard.set(figurines, forKey: Self.figurinesKey) } } + /// Recently opened PGN paths, newest first (File ▸ Open Recent). + private(set) var recentFiles: [String] + /// The files open when the app last ran, in window order — reopened + /// as tabs on launch. + var openFiles: [String] { + didSet { UserDefaults.standard.set(openFiles, forKey: Self.openFilesKey) } + } + + func rememberRecent(_ url: URL) { + let path = url.path + var list = recentFiles.filter { $0 != path } + list.insert(path, at: 0) + recentFiles = Array(list.prefix(8)) + UserDefaults.standard.set(recentFiles, forKey: Self.recentFilesKey) + } + + func clearRecents() { + recentFiles = [] + UserDefaults.standard.removeObject(forKey: Self.recentFilesKey) + } + /// Keychain-backed; nil when unset. private(set) var lichessToken: String? @@ -100,6 +123,11 @@ final class AppSettings { lichessSpeeds = d.string(forKey: Self.speedsKey) ?? "blitz,rapid,classical" referenceSource = ReferenceSource(rawValue: d.string(forKey: Self.sourceKey) ?? "") ?? .database figurines = d.object(forKey: Self.figurinesKey) as? Bool ?? true + recentFiles = d.stringArray(forKey: Self.recentFilesKey) ?? [] + // first launch after the single-list days: the last list becomes the + // first tab, so nothing the user had open goes missing + openFiles = d.stringArray(forKey: Self.openFilesKey) + ?? (d.stringArray(forKey: "lastSourcePaths") ?? []) lichessToken = KeychainStore.get(Self.tokenAccount) // dev hook: a token for a smoke run, without touching the Keychain if let env = ProcessInfo.processInfo.environment["DCS_LICHESS_TOKEN"], !env.isEmpty { diff --git a/app/Studio/Model/DatabaseStore.swift b/app/Studio/Model/DatabaseStore.swift index b534fbf..88dba97 100644 --- a/app/Studio/Model/DatabaseStore.swift +++ b/app/Studio/Model/DatabaseStore.swift @@ -1,3 +1,4 @@ +import AppKit import CryptoKit import Foundation import Observation @@ -5,19 +6,21 @@ import Observation import DanceChessCore #endif -/// The PGN file is the source of truth; SQLite is a per-file speed cache -/// (fast paging for 100k-game lists, opening-tree index). Opening a PGN -/// replaces the list: each file (or multi-selection) gets its own cache db -/// under `/DCStudio/caches/`, rebuilt only when the -/// source file is newer than the cache. Edits (update_game) land in the -/// cache — they survive reopen until the source PGN itself changes. +/// One open PGN file: the list a window shows. The PGN is the source of +/// truth; SQLite is a per-file speed cache (fast paging for 100k-game +/// lists, opening-tree index) under `/DCStudio/caches/`, +/// rebuilt only when the source file is newer than the cache. Edits +/// (update_game) land in the cache and are written back to the file. +/// +/// There is one of these per window, never shared: a file open in two +/// places would be two caches writing the whole file over each other, so +/// the app opens a file once and brings that window forward instead. The +/// live ones are listed in `OpenStores`. @Observable @MainActor final class DatabaseStore { - static let shared = DatabaseStore() - private(set) var db: Database? - /// Display name of what's open (file stem, "+N" for multi-selections). + /// Display name of what's open (the file stem). private(set) var sourceName: String? private(set) var gameCount: UInt64 = 0 /// Bumped whenever the table must drop its page cache and reload @@ -57,49 +60,50 @@ final class DatabaseStore { filteredCount = isFiltered ? ((try? db.countGames(filter: filter)) ?? 0) : gameCount } - /// Source PGN files behind the current list. - private(set) var sourceURLs: [URL] = [] + /// The PGN file behind the list (nil = nothing open in this window). + private(set) var sourceURL: URL? private var cacheURL: URL? - /// Manual entry / write-back needs exactly one source file — a merged - /// multi-file list has no unambiguous home for a new game. - var canWriteBack: Bool { db != nil && sourceURLs.count == 1 } + var canWriteBack: Bool { db != nil && sourceURL != nil } - /// Recently opened PGN paths, newest first (File ▸ Open Recent). - private(set) var recentFiles: [String] = [] + /// The window showing this list, for "that file is already open". + weak var window: NSWindow? - /// Source files already backed up this launch (one .bak per file per - /// run — a safety net for the whole-file write-back). - private var backedUpPaths: Set = [] + func bringWindowForward() { + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } - private static let lastCachePathKey = "lastCachePath" - private static let lastSourceNameKey = "lastSourceName" - private static let lastSourcePathsKey = "lastSourcePaths" - private static let recentFilesKey = "recentFiles" - static let lastSelectedGameKey = "lastSelectedGameId" + /// Source files already backed up this launch (one .bak per file per + /// run — a safety net for the whole-file write-back). App-wide, since + /// the same file can be opened, closed and opened again. + private static var backedUpPaths: Set = [] - private init() { + init() { Self.migrateLegacyStorage() - // reopen the last cache so the app starts where it left off; the - // freshness check against sources only runs on an explicit Open - let defaults = UserDefaults.standard - if let path = defaults.string(forKey: Self.lastCachePathKey), - FileManager.default.fileExists(atPath: path) { - do { - let db = try Database.open(path: path) - self.db = db - cacheURL = URL(fileURLWithPath: path) - gameCount = (try? db.gameCount()) ?? 0 - sourceName = defaults.string(forKey: Self.lastSourceNameKey) - sourceURLs = (defaults.stringArray(forKey: Self.lastSourcePathsKey) ?? []) - .map { URL(fileURLWithPath: $0) } - } catch { - errorText = "Can't open cached list: \(error.localizedDescription)" - } - } else { - statusText = "Open a PGN file to begin" - } - recentFiles = defaults.stringArray(forKey: Self.recentFilesKey) ?? [] + statusText = "Open a PGN file to begin" + } + + /// The canonical form every path comparison uses: one file must be one + /// window whatever the spelling, and a symlink is the same file. + static func canonical(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath() + } + + // MARK: per-file memory (the game you were looking at) + + private var selectedGameKey: String? { + sourceURL.map { "selectedGame:\($0.path)" } + } + + /// The game last viewed in this file, restored when the list opens. + var lastSelectedGameId: Int64? { + guard let key = selectedGameKey else { return nil } + return UserDefaults.standard.object(forKey: key) as? Int64 + } + + func rememberSelected(_ id: Int64) { + if let key = selectedGameKey { UserDefaults.standard.set(id, forKey: key) } } /// One-time move of the pre-rename storage directory (MacBase → @@ -119,19 +123,6 @@ final class DatabaseStore { func setStatus(_ text: String) { statusText = text } - func clearRecents() { - recentFiles = [] - UserDefaults.standard.removeObject(forKey: Self.recentFilesKey) - } - - private func rememberRecent(_ urls: [URL]) { - guard urls.count == 1, let path = urls.first?.path else { return } - var list = recentFiles.filter { $0 != path } - list.insert(path, at: 0) - recentFiles = Array(list.prefix(8)) - UserDefaults.standard.set(recentFiles, forKey: Self.recentFilesKey) - } - /// Synchronous paged fetch for the table's data source; SQLite with /// LIMIT/OFFSET is fast enough to stay on the main thread here. func page(offset: UInt64, limit: UInt32) -> [GameSummary] { @@ -229,7 +220,7 @@ final class DatabaseStore { /// Appends a manually entered game to the list and the source PGN. func addGame(pgn: String) throws -> Int64 { guard let db, canWriteBack else { - throw ChessError.Database(reason: "no single PGN file to save into") + throw ChessError.Database(reason: "no PGN file to save into") } let id = try db.addGame(pgn: pgn) try writeBack() @@ -239,29 +230,24 @@ final class DatabaseStore { return id } - /// Creates an empty PGN file and opens it as the current (0-game) list. - func createNewPgn(at url: URL) { - do { - try Data().write(to: url) - openPgn([url]) - } catch { - errorText = "Can't create file: \(error.localizedDescription)" - } + /// Creates an empty PGN file on disk (the caller then opens it). + static func createEmptyPgn(at url: URL) throws { + try Data().write(to: url) } /// Regenerates the source .pgn from the cache (atomic temp+rename in /// Rust), then touches the cache so it still reads as fresh. private func writeBack() throws { - guard let db, canWriteBack, let source = sourceURLs.first else { return } + guard let db, canWriteBack, let source = sourceURL else { return } let scoped = source.startAccessingSecurityScopedResource() defer { if scoped { source.stopAccessingSecurityScopedResource() } } // first write to this file this launch: keep a .bak of the original - if !backedUpPaths.contains(source.path), + if !Self.backedUpPaths.contains(source.path), FileManager.default.fileExists(atPath: source.path) { let bak = source.path + ".bak" try? FileManager.default.removeItem(atPath: bak) try? FileManager.default.copyItem(atPath: source.path, toPath: bak) - backedUpPaths.insert(source.path) + Self.backedUpPaths.insert(source.path) } try db.writePgnFile(path: source.path) if let cacheURL { @@ -270,20 +256,23 @@ final class DatabaseStore { } } - /// Opens PGN file(s), replacing the current list. Reuses the file's - /// cache when it is newer than every source; otherwise rebuilds it. - func openPgn(_ urls: [URL]) { - guard !urls.isEmpty, !importing else { return } + /// Loads one PGN file into this (empty) store. Reuses the file's cache + /// when it is newer than the source; otherwise rebuilds it. A store + /// loads once: a window shows one file for its whole life. + func load(_ url: URL) { + guard sourceURL == nil, !importing else { return } + let url = Self.canonical(url) importing = true errorText = nil - // sessions still point at games of the previous list — their edits - // become scratch rather than writing into the wrong game - GameSession.SessionRegistry.shared.detachAll() + sourceURL = url + sourceName = url.deletingPathExtension().lastPathComponent + OpenStores.shared.register(self) + AppSettings.shared.rememberRecent(url) Task { var cacheURL: URL? do { - cacheURL = try Self.cacheURL(for: urls) - try await open(urls: urls, cacheURL: cacheURL!) + cacheURL = try Self.cacheURL(for: url) + try await open(url: url, cacheURL: cacheURL!) } catch { // a half-built cache must not pass the next freshness check if let cacheURL { try? FileManager.default.removeItem(at: cacheURL) } @@ -294,17 +283,10 @@ final class DatabaseStore { } } - private func open(urls: [URL], cacheURL: URL) async throws { - let scopes = urls.map { ($0, $0.startAccessingSecurityScopedResource()) } - defer { - for (url, scoped) in scopes where scoped { - url.stopAccessingSecurityScopedResource() - } - } - let name = urls.count == 1 - ? urls[0].deletingPathExtension().lastPathComponent - : "\(urls[0].deletingPathExtension().lastPathComponent) +\(urls.count - 1)" - let fresh = Self.cacheIsFresh(cacheURL, sources: urls) + private func open(url: URL, cacheURL: URL) async throws { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let fresh = Self.cacheIsFresh(cacheURL, source: url) let db = try Database.open(path: cacheURL.path) if fresh { self.db = db @@ -313,61 +295,43 @@ final class DatabaseStore { } else { statusText = "Importing…" try await Self.runClear(db: db) - var imported: UInt32 = 0 - var skipped: UInt32 = 0 - var millis: UInt64 = 0 - for url in urls { - let stats = try await Self.runImport(db: db, path: url.path) - imported += stats.imported - skipped += stats.skipped - millis += stats.millis - } + let stats = try await Self.runImport(db: db, path: url.path) self.db = db gameCount = (try? db.gameCount()) ?? 0 statusText = String(format: "imported %d games (%d skipped) in %.1fs", - imported, skipped, Double(millis) / 1000) + stats.imported, stats.skipped, Double(stats.millis) / 1000) } - sourceName = name - sourceURLs = urls self.cacheURL = cacheURL filter = GameFilter(text: nil, result: nil, dateFrom: nil, dateTo: nil, minElo: nil, maxElo: nil, fen: nil) filteredCount = gameCount generation += 1 revision += 1 - UserDefaults.standard.set(cacheURL.path, forKey: Self.lastCachePathKey) - UserDefaults.standard.set(name, forKey: Self.lastSourceNameKey) - UserDefaults.standard.set(urls.map(\.path), forKey: Self.lastSourcePathsKey) - rememberRecent(urls) } - /// One cache db per source selection, keyed by the full path set. - private static func cacheURL(for urls: [URL]) throws -> URL { + /// One cache db per source file, keyed by its canonical path. + private static func cacheURL(for url: URL) throws -> URL { let support = try FileManager.default .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let dir = support.appendingPathComponent("DCStudio/caches", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let key = urls.map(\.path).sorted().joined(separator: "\n") - let hex = SHA256.hash(data: Data(key.utf8)) + let hex = SHA256.hash(data: Data(url.path.utf8)) .prefix(8).map { String(format: "%02x", $0) }.joined() - let stem = urls[0].deletingPathExtension().lastPathComponent + let stem = url.deletingPathExtension().lastPathComponent .replacingOccurrences(of: "/", with: "-") return dir.appendingPathComponent("\(stem)-\(hex).db") } - /// Fresh = the cache file is newer than every source PGN. Edits keep + /// Fresh = the cache file is newer than the source PGN. Edits keep /// bumping the cache's mtime, so they never mark it stale themselves. - private static func cacheIsFresh(_ cache: URL, sources: [URL]) -> Bool { + private static func cacheIsFresh(_ cache: URL, source: URL) -> Bool { let fm = FileManager.default guard let attrs = try? fm.attributesOfItem(atPath: cache.path), - let cacheDate = attrs[.modificationDate] as? Date else { return false } - for url in sources { - guard let a = try? fm.attributesOfItem(atPath: url.path), - let sourceDate = a[.modificationDate] as? Date, - sourceDate <= cacheDate else { return false } - } - return true + let cacheDate = attrs[.modificationDate] as? Date, + let a = try? fm.attributesOfItem(atPath: source.path), + let sourceDate = a[.modificationDate] as? Date else { return false } + return sourceDate <= cacheDate } private nonisolated static func runClear(db: Database) async throws { @@ -380,3 +344,51 @@ final class DatabaseStore { }.value } } + + +/// The files open right now, one store each — what "open this file" checks +/// before opening it again, what Copy Games To lists, and what is written +/// down for the next launch. Weak, so a closed window drops out on its own. +@MainActor +final class OpenStores { + static let shared = OpenStores() + private struct WeakBox { weak var store: DatabaseStore? } + private var boxes: [WeakBox] = [] + + var all: [DatabaseStore] { boxes.compactMap(\.store) } + + /// Set when the app is quitting: the windows close one by one from + /// here on, and each one leaving must not shrink the restore list — + /// that is exactly the set the next launch should bring back. Found + /// the hard way: a headless test that killed the process restored + /// fine, and a real ⌘Q came back to nothing. + private(set) var quitting = false + + func freezeForQuit() { + persist() + quitting = true + } + + func register(_ store: DatabaseStore) { + boxes.removeAll { $0.store == nil || $0.store === store } + boxes.append(WeakBox(store: store)) + persist() + } + + /// A window closed. During a quit the list is already frozen. + func unregister(_ store: DatabaseStore) { + boxes.removeAll { $0.store == nil || $0.store === store } + if !quitting { persist() } + } + + /// The store showing `url`, if any window has it. + func store(for url: URL) -> DatabaseStore? { + let key = DatabaseStore.canonical(url) + return all.first { $0.sourceURL == key } + } + + /// The open files, in window order, for restoring on the next launch. + private func persist() { + AppSettings.shared.openFiles = all.compactMap { $0.sourceURL?.path } + } +} diff --git a/app/Studio/Model/EngineSession.swift b/app/Studio/Model/EngineSession.swift index 584b775..8b3f074 100644 --- a/app/Studio/Model/EngineSession.swift +++ b/app/Studio/Model/EngineSession.swift @@ -97,6 +97,26 @@ final class EngineSession { setTarget(fen) } + /// The window went to the back (another tab, another window): stop + /// burning CPU on a board nobody is looking at. `resume` picks the + /// same position back up. Several open tabs with the panel on would + /// otherwise be several engines at full thread count each. + private(set) var suspended = false + + func suspend() { + guard enabled, !suspended else { return } + suspended = true + enabled = false + engine?.stop() + } + + func resume() { + guard suspended else { return } + suspended = false + enabled = panelVisible + if enabled { setTarget(currentFen.isEmpty ? nil : currentFen) } + } + /// Stops analysis and tears the subprocess down (window closing). func shutdown() { enabled = false diff --git a/app/Studio/Model/GameSession.swift b/app/Studio/Model/GameSession.swift index 49f1bbe..3d7741b 100644 --- a/app/Studio/Model/GameSession.swift +++ b/app/Studio/Model/GameSession.swift @@ -164,6 +164,9 @@ final class GameSession { } /// `games.id` this session was loaded from, or -1 for a scratch game. private(set) var sourceGameId: Int64 = -1 + /// The list that game came from — where a save goes. Set by the + /// window that owns the session; nil for a board with no file behind it. + weak var store: DatabaseStore? /// Canonical PGN at load/save time; `isModified` compares against it, so /// there is no per-edit flag to keep in sync (games are small — cheap). private var baselinePgn = "" @@ -610,14 +613,11 @@ final class GameSession { boxes.append(WeakBox(session: session)) } - /// Live sessions with unsaved changes to a database game. + /// Live sessions with unsaved changes to a database game whose + /// file is still open (a closed file has nowhere to save to). var modified: [GameSession] { - boxes.compactMap(\.session).filter { $0.sourceGameId >= 0 && $0.isModified } - } - - /// The opened list is being replaced: no session id is valid anymore. - func detachAll() { - for box in boxes { box.session?.detachFromDatabase() } + boxes.compactMap(\.session) + .filter { $0.sourceGameId >= 0 && $0.isModified && $0.store != nil } } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0013187..7a74c22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -78,7 +78,17 @@ cache removes a whole class of invalidation bugs. DC Studio does not own your games. Opening a `.pgn` imports it into a SQLite cache under `Application Support/DCStudio/caches/`, one database per file, -keyed by the file's path. The cache is considered fresh when its modification +keyed by the file's canonical path. + +**One file, one window, one `DatabaseStore`.** The store is not a singleton: +each window owns the store for its file, and `OpenStores` lists the live +ones (weakly). That is what makes the write-back safe with several files +open — a file in two windows would be two caches regenerating the whole file +over each other, so `FileOpener` refuses the second open and brings the +first window forward. The check is a *claim* made the moment a window is +assigned a file, not a lookup of loaded stores: SwiftUI applies a window's +value on the next update pass, and two opens in one turn would otherwise +both see an empty registry. The cache is considered fresh when its modification time is at or after every source file's — so edits made inside the app survive across launches, while a `.pgn` modified by another program forces a rebuild. @@ -151,6 +161,19 @@ rendered by `BoardImage`, tagged with the move's node id like any other run. ## The Swift side +- Windows are a `WindowGroup(for: URL.self)`: the value is the file. Native + tabbing (`tabbingMode = .preferred`, one `tabbingIdentifier`) groups them; + since SwiftUI has already shown a new window by the time AppKit hands it + over, joining the group is done by hand with `addTabbedWindow`. The + window SwiftUI opens at launch has no value; the first `MainWindow` to + appear restores the previous session into it and opens the rest as tabs. +- `FileOpener` is how code without a SwiftUI environment (the app delegate, + the tab bar's "+", a standalone game window) opens files: the newest main + window lends it `openWindow`. +- `EngineSession` suspends when its window resigns key and resumes when it + becomes key again, so several tabs with the panel open are one running + search, not several. + - `app/Package.swift` is a SwiftPM harness that builds and runs the whole app with only the Command Line Tools. `app/project.yml` (XcodeGen) is optional and mirrors the same sources; code that must work in both uses diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 89b70f9..a7f3b8c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,8 +15,14 @@ General position and material search is explicitly **out of scope** — the only a per-file cache; saving writes back to the source file, atomically, with a `.pgn.bak` kept from before the session's first write. New files, manual game entry with a ChessBase-style save mask, delete, and per-game PGN export all -round-trip through the same path. Recent files and the last game you were -looking at are restored on launch. +round-trip through the same path. + +**One window per file, as tabs.** Every `.pgn` you open gets its own window, +and windows tab together the way Safari's do (drag them apart if you prefer +windows). A file is opened once: opening it again brings its tab forward. Each +tab keeps its own list, filter, selection and engine; only the front tab's +engine runs. The files that were open come back as tabs on the next launch, +each on the game you were looking at. **Board and notation.** Click or drag to move, promotion picker, a variation chooser when a move has several continuations, board flip, coordinates. The @@ -90,14 +96,15 @@ game, which is what makes studies and tactics puzzles usable. - **Not notarized.** The released build is ad-hoc signed, so macOS quarantines it on first launch. See the install notes in the README. - **Apple Silicon only.** No Intel build. -- **One file at a time is the supported path.** You can open several `.pgn` - files into one merged list, but saving is disabled for merged lists — there - is no defined file to write back to. - **A source file edited outside the app discards in-app edits.** Cache - freshness is a modification-time comparison; if the `.pgn` is newer, the - cache is rebuilt from it. Save before editing the file elsewhere. -- **No multi-database management**, no cross-file search, no player or - tournament index, no position or material search. + freshness is a modification-time comparison made when the file is opened; + if the `.pgn` is newer, the cache is rebuilt from it. Save before editing + the file elsewhere — and with several tabs open for a long session, this + matters more than it used to. A file watcher is the next step. +- **Nothing moves between tabs yet.** Copying games from one file to another + is the second half of the multi-file work. +- **No cross-file search**, no player or tournament index, no position or + material search. A search is about one file — the tab it runs in. - **Analysis uses a fixed depth**; there is no time budget, no second engine, and no tablebases. - **Printing is the notation as shown**; there is no page layout to speak of