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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ coaches on macOS. Part of the DanceChess family.

## Features

- **One window per PGN, as tabs** — board + notation on top, that file's game list below; open files come back on launch.
- **One window per PGN, as tabs** — board + notation on top, that file's game list below; open files come back on launch. Copy games between tabs (duplicates skipped); a file changed by another program is flagged.
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
Expand Down
43 changes: 42 additions & 1 deletion app/Studio/Database/GameListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ struct GameListView: NSViewRepresentable {
let onDeleteRequest: ((Int64) -> Void)?
/// Right-click with several rows selected → Merge Selected Games.
var onMergeRequest: (([Int64]) -> Void)? = nil
/// Right-click → Copy to ▸ another open file.
var onCopyRequest: (([Int64], DatabaseStore) -> 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
Expand Down Expand Up @@ -85,6 +87,11 @@ struct GameListView: NSViewRepresentable {
keyEquivalent: "")
merge.target = context.coordinator
menu.addItem(merge)
// filled in when the menu opens: the other files open right now
let copy = NSMenuItem(title: "Copy to", action: nil, keyEquivalent: "")
copy.submenu = NSMenu(title: "Copy to")
menu.addItem(copy)
menu.delegate = context.coordinator
table.menu = menu
// initial sort indicator mirrors the store (file order)
table.sortDescriptors = [NSSortDescriptor(key: "number", ascending: true)]
Expand Down Expand Up @@ -118,7 +125,7 @@ struct GameListView: NSViewRepresentable {
}

@MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
struct ColumnSpec {
let id: String
let title: String
Expand Down Expand Up @@ -373,6 +380,39 @@ struct GameListView: NSViewRepresentable {
return table.selectedRowIndexes.compactMap { summary(at: $0)?.id }
}

/// The context menu is opening: list every other open file under
/// "Copy to". Built each time, since tabs come and go.
func menuNeedsUpdate(_ menu: NSMenu) {
guard let item = menu.items.first(where: { $0.title == "Copy to" }),
let sub = item.submenu else { return }
sub.removeAllItems()
let targets = OpenStores.shared.all.filter { $0 !== view.store && $0.canWriteBack }
for target in targets {
let entry = NSMenuItem(title: target.sourceName ?? "?",
action: #selector(copyClicked(_:)), keyEquivalent: "")
entry.target = self
entry.representedObject = target
sub.addItem(entry)
}
if targets.isEmpty {
let none = NSMenuItem(title: "No other file open", action: nil, keyEquivalent: "")
none.isEnabled = false
sub.addItem(none)
}
item.isEnabled = view.onCopyRequest != nil && !targets.isEmpty
&& (!selectedIds.isEmpty || (table?.clickedRow ?? -1) >= 0)
}

@objc func copyClicked(_ sender: NSMenuItem) {
guard let target = sender.representedObject as? DatabaseStore else { return }
var ids = selectedIds
if ids.isEmpty, let table, table.clickedRow >= 0, let g = summary(at: table.clickedRow) {
ids = [g.id]
}
guard !ids.isEmpty else { return }
view.onCopyRequest?(ids, target)
}

@objc func mergeClicked(_ sender: Any?) {
let ids = selectedIds
guard ids.count >= 2 else { return }
Expand All @@ -389,6 +429,7 @@ struct GameListView: NSViewRepresentable {
if item.action == #selector(mergeClicked(_:)) {
return view.onMergeRequest != nil && selectedIds.count >= 2
}
if item.action == #selector(copyClicked(_:)) { return true }
return view.onDeleteRequest != nil && (table?.clickedRow ?? -1) >= 0
}
}
Expand Down
64 changes: 64 additions & 0 deletions app/Studio/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ struct MainWindow: View {
.frame(minHeight: 380)

VStack(spacing: 0) {
if store.externallyChanged { changedOnDiskBanner }
searchBar
Divider()
GameListView(
Expand All @@ -95,6 +96,7 @@ struct MainWindow: View {
},
onDeleteRequest: { confirmAndDelete($0) },
onMergeRequest: { mergeGames($0) },
onCopyRequest: { ids, target in copyGames(ids, to: target) },
initialSelection: store.lastSelectedGameId
)
Divider()
Expand Down Expand Up @@ -165,6 +167,30 @@ struct MainWindow: View {
NSApp.sendAction(#selector(NSResponder.newWindowForTab(_:)), to: nil, from: nil)
}
}
// dev hook: copy games 1–2 of THIS window's file into the other
// open file, then report (run twice to see duplicates skipped)
if let which = ProcessInfo.processInfo.environment["DCS_AUTO_COPY"],
url?.lastPathComponent == which {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
guard let target = OpenStores.shared.all.first(where: { $0 !== store }) else { return }
let before = target.gameCount
copyGames([1, 2], to: target)
let out = ProcessInfo.processInfo.environment["DCS_AUTO_COPY_OUT"] ?? "/tmp/dcs-copy.txt"
try? "target before: \(before) after: \(target.gameCount)\nsource status: \(store.statusText ?? "-")\ntarget status: \(target.statusText ?? "-")\n"
.write(toFile: out, atomically: true, encoding: .utf8)
}
}
// dev hook: the file-changed banner — report after a delay,
// optionally reloading first
if let out = ProcessInfo.processInfo.environment["DCS_AUTO_WATCH_OUT"], url != nil {
if ProcessInfo.processInfo.environment["DCS_AUTO_WATCH_RELOAD"] != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { store.reloadFromDisk() }
}
DispatchQueue.main.asyncAfter(deadline: .now() + 7) {
try? "changed: \(store.externallyChanged)\ngames: \(store.gameCount)\nstatus: \(store.statusText ?? "-")\n"
.write(toFile: out, atomically: true, encoding: .utf8)
}
}
// 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) {
Expand Down Expand Up @@ -428,6 +454,44 @@ struct MainWindow: View {
.frame(minWidth: 860, minHeight: 600)
}

/// The source file changed under us. Reload throws this cache away —
/// including unsaved edits to this file's games — and takes the file as
/// it is now; Keep Mine carries on, and the next save replaces the
/// file (with a .pgn.bak kept).
private var changedOnDiskBanner: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("“\(store.sourceURL?.lastPathComponent ?? "This file")” was changed by another program.")
.font(.system(size: 12))
Spacer()
Button("Reload") { store.reloadFromDisk() }
.help("Take the file as it is on disk now. Unsaved edits to its games are lost.")
Button("Keep Mine") { store.keepMine() }
.help("Keep what this window has; the next save replaces the file (a .pgn.bak is kept).")
}
.controlSize(.small)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.yellow.opacity(0.18))
}

/// Copy to ▸ another open file: whole games, duplicates (same moves)
/// skipped, the target written back once. Both tabs say what happened.
private func copyGames(_ ids: [Int64], to target: DatabaseStore) {
let pgns = ids.compactMap { store.pgn(for: $0) }
guard !pgns.isEmpty else { return }
do {
let r = try target.copyGames(pgns)
let what = r.duplicates == 0
? "\(r.copied) game\(r.copied == 1 ? "" : "s")"
: "\(r.copied) game\(r.copied == 1 ? "" : "s") (\(r.duplicates) already there)"
store.setStatus("copied \(what) to “\(target.sourceName ?? "?")”")
target.setStatus("\(what) copied in from “\(store.sourceName ?? "?")”")
} catch {
store.setStatus("copy failed: \(error.localizedDescription)")
}
}

private var searchBar: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
Expand Down
148 changes: 147 additions & 1 deletion app/Studio/Model/DatabaseStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ final class DatabaseStore {
try? FileManager.default.setAttributes(
[.modificationDate: Date()], ofItemAtPath: cacheURL.path)
}
// our own write; the watcher must not read it as somebody else's
noteSourceDate()
// the rename swapped the inode under the descriptor
startWatching()
}

/// Loads one PGN file into this (empty) store. Reuses the file's cache
Expand Down Expand Up @@ -286,8 +290,9 @@ final class DatabaseStore {
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)
// newer than the file, AND laid out the way this version expects
let fresh = Self.cacheIsFresh(cacheURL, source: url) && !db.needsRebuild()
if fresh {
self.db = db
gameCount = (try? db.gameCount()) ?? 0
Expand All @@ -307,6 +312,147 @@ final class DatabaseStore {
filteredCount = gameCount
generation += 1
revision += 1
noteSourceDate()
startWatching()
}

// MARK: copying games in from another file

/// Appends games (as PGN text) that are not already here — "already"
/// meaning the same start position and main line, whatever the
/// headers say — then writes the file back once. Returns how many
/// landed and how many were duplicates.
func copyGames(_ pgns: [String]) throws -> (copied: Int, duplicates: Int) {
guard let db, canWriteBack else {
throw ChessError.Database(reason: "no PGN file to copy into")
}
var copied = 0, duplicates = 0
for pgn in pgns {
if (try? db.findDuplicate(pgn: pgn)) ?? nil != nil {
duplicates += 1
continue
}
_ = try db.addGame(pgn: pgn)
copied += 1
}
if copied > 0 {
try writeBack()
gameCount = (try? db.gameCount()) ?? gameCount
recount()
revision += 1
}
return (copied, duplicates)
}

// MARK: the source file changing under us

/// Another program wrote the file since it was loaded (or since we
/// last wrote it). The window shows a banner; `reloadFromDisk` or
/// `keepMine` clears it. Until one is chosen, saving would overwrite
/// the other program's work with this cache — which is also what
/// `keepMine` chooses, deliberately, with the .bak as the safety net.
private(set) var externallyChanged = false
private var watcher: DispatchSourceFileSystemObject?
private var watchedFD: Int32 = -1
/// The file's modification date as we last knew it — after loading it
/// and after each write of our own, which is how our own writes are
/// told apart from somebody else's.
private var knownSourceDate: Date?

private func sourceDate() -> Date? {
guard let path = sourceURL?.path,
let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { return nil }
return attrs[.modificationDate] as? Date
}

private func noteSourceDate() { knownSourceDate = sourceDate() }

private func startWatching() {
stopWatching()
guard let path = sourceURL?.path else { return }
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return }
watchedFD = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd, eventMask: [.write, .rename, .delete, .attrib, .extend], queue: .main)
source.setEventHandler { [weak self] in
guard let self else { return }
let flags = source.data
MainActor.assumeIsolated { self.fileEvent(flags) }
}
source.setCancelHandler { close(fd) }
source.resume()
watcher = source
}

private func stopWatching() {
watcher?.cancel()
watcher = nil
watchedFD = -1
}

private func fileEvent(_ flags: DispatchSource.FileSystemEvent) {
// editors save by writing a new file and renaming it over ours:
// the descriptor now points at the old inode, so watch the path
// again once the dust settles
if flags.contains(.rename) || flags.contains(.delete) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return }
MainActor.assumeIsolated {
self.startWatching()
self.checkForExternalChange()
}
}
return
}
checkForExternalChange()
}

private func checkForExternalChange() {
guard !externallyChanged, let now = sourceDate() else { return }
if let known = knownSourceDate, now.timeIntervalSince(known) <= 0.5 { return }
externallyChanged = true
}

/// Throw this cache away and import the file as it is now. Games of
/// this file open in a session are detached first: after a re-import
/// the ids are file order again, and a session still holding an old
/// id could save into a different game.
func reloadFromDisk() {
guard let url = sourceURL, let db, !importing else { return }
externallyChanged = false
importing = true
GameSession.SessionRegistry.shared.detach(store: self)
Task {
do {
statusText = "Reloading…"
try await Self.runClear(db: db)
let stats = try await Self.runImport(db: db, path: url.path)
gameCount = (try? db.gameCount()) ?? 0
statusText = String(format: "reloaded: %d games (%d skipped) in %.1fs",
stats.imported, stats.skipped, Double(stats.millis) / 1000)
if let cacheURL {
try? FileManager.default.setAttributes(
[.modificationDate: Date()], ofItemAtPath: cacheURL.path)
}
filter = GameFilter(text: nil, result: nil, dateFrom: nil, dateTo: nil,
minElo: nil, maxElo: nil, fen: nil)
filteredCount = gameCount
generation += 1
revision += 1
noteSourceDate()
} catch {
errorText = "Reload failed — \(error.localizedDescription)"
}
importing = false
}
}

/// Dismiss the banner and carry on with this cache; the next save
/// replaces the file (a .pgn.bak of the current file is kept).
func keepMine() {
externallyChanged = false
noteSourceDate()
}

/// One cache db per source file, keyed by its canonical path.
Expand Down
8 changes: 8 additions & 0 deletions app/Studio/Model/GameSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,14 @@ final class GameSession {
boxes.compactMap(\.session)
.filter { $0.sourceGameId >= 0 && $0.isModified && $0.store != nil }
}

/// The file is being re-imported: ids of its games are about to
/// change, so no session may keep one.
func detach(store: DatabaseStore) {
for s in boxes.compactMap(\.session) where s.store === store {
s.detachFromDatabase()
}
}
}

private func applyFen() {
Expand Down
Loading
Loading