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
4 changes: 4 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ disabled_rules:
- trailing_comma
- multiple_closures_with_trailing_closure

file_length:
warning: 400
ignore_comment_only_lines: true # doc comments are a feature here, not bulk

line_length:
warning: 120
error: 200
Expand Down
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw
content fills everything). `isMovableByWindowBackground` is **off** on purpose.
- **`NoteWindowManager`** (`@MainActor`) — owns `[UUID: NotePanel]` + `[UUID: NoteController]`, is
each panel's `NSWindowDelegate`, and is the single chokepoint for all window mutation:
`restoreAll` (launch), `openNote`, close, debounced frame persistence, roll-up resize, and
applying float-on-top / show-on-all-Spaces.
`restoreAll` (launch: only notes with `isOpen` — the header X clears it, quitting doesn't, so
relaunch shows what was on screen), `openNote`, close, debounced frame persistence, roll-up
resize, and applying float-on-top / show-on-all-Spaces. Frames are global coordinates spanning
all displays; `ensureOnScreen` recentres only when *no* screen contains the note.
- **`NoteController`** (`@MainActor @Observable`) — one per open note. Holds the `Note`, streams
its tasks live via GRDB `ValueObservation`, and turns user actions into DB writes. It stays
**AppKit-free**: window side effects go through closures the manager sets on it
Expand All @@ -79,7 +81,8 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw
id-matched `indentLevelChanges` diff. Kept separate so it's unit-testable in isolation.
- **`PlainTextEditor`** (`NSViewRepresentable` over `NSTextView`) — the task / quick-add editor.
AppKit, not SwiftUI `TextField`, because that control can't reliably insert newlines or intercept
Tab on macOS (see the editing convention below). **`ShortcutHint`** is the small keycap-chip label.
Tab on macOS (see the editing convention below). **`QuickAddBar`** is the add-a-task bar along the
bottom of a note (owns the typed text and pending indent). **`ShortcutHint`** is the small keycap-chip label.
- **`TaskImage`** (pure CoreGraphics/ImageIO, no AppKit / no DB) + **`TaskImageView`** — pasted images:
normalise/thumbnail/crop/preview-file helpers and the crop maths (`dragging`, `moving`), and the view
that draws a task's image with its hover buttons, context menu, and crop mode. **`ImageViewer`** is the
Expand Down
7 changes: 7 additions & 0 deletions Sources/Tic/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

/// Click the Dock icon with no notes on screen → open the Lists palette, so the app never
/// looks dead after every note was closed.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
if AppModel.shared.windows.openCount == 0 { AppModel.shared.openSearch() }
return true
}

/// Right-click the Dock icon → New List.
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let menu = NSMenu()
Expand Down
4 changes: 4 additions & 0 deletions Sources/Tic/Controllers/NoteController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,11 @@ final class NoteController {
return TaskCompletionUpdate(id: a.id, isDone: a.isDone, completedAt: a.completedAt)
}
}
}

// MARK: - Note-level actions: title, appearance, window behaviour, display options, lifecycle

extension NoteController {
// MARK: - Note actions

func commitTitle(_ rawTitle: String) {
Expand Down
189 changes: 189 additions & 0 deletions Sources/Tic/Database/AppDatabase+Tasks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import Foundation
import GRDB

// MARK: - Tasks and task images

extension AppDatabase {
// MARK: - Tasks

func tasks(noteId: UUID) async throws -> [TaskItem] {
try await dbQueue.read { db in
try TaskItem
.filter(TaskItem.Columns.noteId == noteId)
.order(TaskItem.Columns.sortIndex)
.fetchAll(db)
}
}

func insert(_ task: TaskItem) async throws {
try await dbQueue.write { db in try task.insert(db) }
}

/// Inserts a task assigning the next `sortIndex` (`MAX + 1` for the note) atomically inside the
/// write, so a `tasks.count`-based index can't collide with a stale row after deletes leave gaps.
/// `imageData`, if given, is stored as the task's image in the same transaction.
func insertTask(_ task: TaskItem, imageData: Data? = nil) async throws {
let snapshot = task
try await dbQueue.write { db in
let maxIndex = try Int.fetchOne(
db, sql: "SELECT MAX(sortIndex) FROM task WHERE noteId = ?", arguments: [snapshot.noteId]
) ?? -1
var stored = snapshot
stored.sortIndex = maxIndex + 1
try stored.insert(db)
if let imageData { try Self.writeImage(imageData, taskId: stored.id, db) }
}
}

/// Inserts `task` somewhere in the middle of a note's list (e.g. a subtask nested under a row),
/// then renumbers every row's `sortIndex` to its position in `ordered` — all in one transaction
/// so the observation sees a single consistent ordering. `ordered` is the full intended list
/// *including* the new task.
func insertTask(_ task: TaskItem, reordering ordered: [TaskItem]) async throws {
let snapshot = task
let order = ordered
try await dbQueue.write { db in
try snapshot.insert(db)
for (index, t) in order.enumerated() {
try db.execute(sql: "UPDATE task SET sortIndex = ? WHERE id = ?", arguments: [index, t.id])
}
}
}

func update(_ task: TaskItem) async throws {
try await dbQueue.write { db in try task.update(db) }
}

/// One atomic transaction for a structural edit (indent / outdent / delete / drag-reorder /
/// clear-completed), so the live observation never sees a transient invalid outline. Touches only
/// the structural columns (`sortIndex`, `indentLevel`) plus optional row deletes — never `text` or
/// the completion columns, so a structural change can neither clobber a concurrently-edited task
/// body nor alter any tick state (completion only ever changes via `updateTaskCompletion`).
/// `deleteIds` may hold one row (single delete) or many (clear-completed), removed in one `IN (…)`.
func applyStructuralUpdate(
deleteIds: [UUID] = [],
reorder ordered: [TaskItem]? = nil,
levels: [TaskLevelUpdate] = []
) async throws {
try await dbQueue.write { db in
if !deleteIds.isEmpty {
_ = try TaskItem.filter(deleteIds.contains(TaskItem.Columns.id)).deleteAll(db)
}
if let ordered {
for (index, task) in ordered.enumerated() where task.sortIndex != index {
try db.execute(sql: "UPDATE task SET sortIndex = ? WHERE id = ?", arguments: [index, task.id])
}
}
for u in levels {
try db.execute(sql: "UPDATE task SET indentLevel = ? WHERE id = ?", arguments: [u.level, u.id])
}
}
}

/// Targeted write of the `isDone` + `completedAt` columns for the given tasks (a checkbox
/// toggle, which cascades through a subtree and bubbles up to ancestors). Writes only the
/// completion columns so it never clobbers a concurrently-edited `text` or a reorder.
func updateTaskCompletion(_ updates: [TaskCompletionUpdate]) async throws {
guard !updates.isEmpty else { return }
try await dbQueue.write { db in
try Self.writeCompletion(updates, db)
}
}

/// Writes `isDone` + `completedAt` for each update, inside an existing write transaction.
private static func writeCompletion(_ updates: [TaskCompletionUpdate], _ db: Database) throws {
for u in updates {
try db.execute(
sql: "UPDATE task SET isDone = ?, completedAt = ? WHERE id = ?",
arguments: [u.isDone, u.completedAt, u.id]
)
}
}

/// Emits a note's ordered tasks whenever any of them change.
func observeTasks(noteId: UUID) -> AsyncValueObservation<[TaskItem]> {
ValueObservation
.tracking { db in
try TaskItem
.filter(TaskItem.Columns.noteId == noteId)
.order(TaskItem.Columns.sortIndex)
.fetchAll(db)
}
.values(in: dbQueue)
}

// MARK: - Task images

/// Stores (or replaces) a task's image. A replaced picture starts uncropped again.
func setTaskImage(taskId: UUID, data: Data) async throws {
try await dbQueue.write { db in try Self.writeImage(data, taskId: taskId, db) }
}

/// `INSERT OR REPLACE` swaps the whole row, so the crop columns fall back to their full-image defaults.
private static func writeImage(_ data: Data, taskId: UUID, _ db: Database) throws {
try db.execute(
sql: "INSERT OR REPLACE INTO taskImage (taskId, data, updatedAt) VALUES (?, ?, ?)",
arguments: [taskId, data, Date()]
)
}

/// Targeted write of just an image's crop, so it can't race a replace or a task text commit.
func updateTaskImageCrop(taskId: UUID, crop: CGRect) async throws {
try await dbQueue.write { db in
try db.execute(
sql: "UPDATE taskImage SET cropX = ?, cropY = ?, cropW = ?, cropH = ?, updatedAt = ? WHERE taskId = ?",
arguments: [
Double(crop.minX), Double(crop.minY), Double(crop.width), Double(crop.height), Date(), taskId,
]
)
}
}

/// Removes a task's image, keeping the task.
func deleteTaskImage(taskId: UUID) async throws {
try await dbQueue.write { db in
try db.execute(sql: "DELETE FROM taskImage WHERE taskId = ?", arguments: [taskId])
}
}

/// The stored (original, uncropped) image bytes — read on demand, never via an observation.
func taskImageData(taskId: UUID) async throws -> Data? {
try await dbQueue.read { db in
try Data.fetchOne(db, sql: "SELECT data FROM taskImage WHERE taskId = ?", arguments: [taskId])
}
}

/// Emits the crop of every image in a note, keyed by task id. Deliberately never selects `data`, so
/// the blobs are only read on demand (`taskImageData`).
func observeTaskImageCrops(noteId: UUID) -> AsyncValueObservation<[UUID: CGRect]> {
ValueObservation
.tracking { db in
let rows = try Row.fetchAll(db, sql: """
SELECT i.taskId, i.cropX, i.cropY, i.cropW, i.cropH
FROM taskImage i JOIN task t ON t.id = i.taskId
WHERE t.noteId = ?
""", arguments: [noteId])
return Dictionary(uniqueKeysWithValues: rows.map { row in
let crop = CGRect(
x: row["cropX"] as Double, y: row["cropY"] as Double,
width: row["cropW"] as Double, height: row["cropH"] as Double
)
return (row["taskId"] as UUID, crop)
})
}
.values(in: dbQueue)
}
}

/// A targeted `indentLevel` change for one task — batched into `applyStructuralUpdate`.
struct TaskLevelUpdate: Sendable {
let id: UUID
let level: Int
}

/// A targeted completion change for one task — batched by `updateTaskCompletion`.
struct TaskCompletionUpdate: Sendable {
let id: UUID
let isDone: Bool
let completedAt: Date?
}
Loading