5 Commits
Author SHA1 Message Date
TZGyn 78d56ca79b Make dark mode black instead of the system window grey
The panel is a full-height strip that sits beside your work, where the
system grey reads as a half-lit window next to a dark editor. Dark mode
is now pure black, with card and row colours in one Theme file so both
the panel background and the strip under the titlebar match.
2026-10-09 18:04:08 +08:00
TZGyn 6580a79d40 Select items by clicking the card instead of copying them
Copying was a side effect of a click, which made it impossible to build
a selection with the mouse and surprising when the intent was to pick
something up rather than copy it immediately. A click now selects (or
deselects), and copying is an explicit action from the selection bar or
the right-click menu.
2026-10-09 17:58:38 +08:00
TZGyn c7b4ed32f2 Make the row checkbox select items instead of ticking them off
The checkbox was marking items done, but the intent was to gather items
and act on them: tick several, then right-click to copy, copy as a list,
move, merge or delete them all at once. Done is still available from the
same menu, it is just no longer what the checkbox does.
2026-10-09 17:54:54 +08:00
TZGyn 9f0c0f8113 Make pasting reliable and turn items into checkboxes
Pasting still failed because the panel is a nonactivating panel: it can
be key while the app is not active, and menu key equivalents are not
delivered reliably in that state. Cmd+V is now dispatched to the
responder chain directly, and the app no longer replaces SwiftUI's main
menu — it only adds Hide Panel to it, so the Edit items that AppKit
routes text shortcuts through stay intact.

Rows also gained a checkbox so a workspace works as a checklist without
turning the row into a button, which would swallow the checkbox's clicks.
2026-10-09 17:49:06 +08:00
TZGyn 93cd6f2056 Restore pasting and rename sections to workspaces
Replacing the main menu dropped the Edit menu that AppKit routes
Cmd+V/C/A/Z through, so the composer could not paste at all. The menu is
rebuilt with those items, which --diag now dumps so the wiring can be
checked without a keyboard.

Workspaces also gained an order: the Cmd+K switcher has up/down controls,
creating one opens a naming modal instead of prefilling the composer, and
the UI now says workspace throughout. The stored JSON keeps the original
`sections` keys so existing data and sync payloads stay valid.
2026-10-09 17:29:16 +08:00
7 changed files with 348 additions and 122 deletions
+34 -6
View File
@@ -27,14 +27,23 @@ clicking an item copies it back to the clipboard.
`Images/` next to it, cached in an `NSCache`. Owns all mutations (add,
toggle done, edit, delete, move, merge, clear done) and clipboard writes
(`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines;
both text modes skip image items). Saves on every mutation.
both text modes skip image items). Batch mutations (`delete(ids:)`,
`setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one
action applies to every selected item and image files are cleaned up once.
Saves on every mutation.
- `Sources/Prompt/Theme.swift` — the panel's appearance-aware colours. Dark
mode is pure black rather than the system window grey (the panel is a
full-height strip beside your work, where grey reads as a half-lit window);
light mode keeps the system window colour. `Theme.background` is also handed
to the `NSPanel` so the strip under the transparent titlebar matches, and the
row colour stays slightly lighter than black so cards remain visible.
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher,
search, add photos, close), item list, composer. Composer `# Name` creates/
switches sections. Click = copy; Cmd+click toggles multi-select, Shift+click
selects a range, and a selection bar offers Copy as List / Copy as Lines;
right-click = context menu (Copy, Copy as List, Copy as Lines, Done, Edit,
Open, Merge, Move To, Delete). Esc clears the selection before it hides the
panel. Drop target for images/files.
switches sections. Click = select (Shift+click for a range); the selection
bar offers Copy (single item), Copy as List and Copy as Lines; right-click =
context menu acting on the whole selection (Copy, Copy as List, Copy as
Lines, Done, Edit, Open, Merge, Move To, Delete). Esc clears the selection
before it hides the panel. Drop target for images/files.
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
(`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as
`PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController`
@@ -156,6 +165,25 @@ brew upgrade --cask prompt
## Gotchas
- **Never assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items
are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu
silently breaks pasting, copying and select-all in the composer (this shipped
once and had to be reverted). `installMenuItems()` only *augments* the
existing menu: it adds Hide Panel (Cmd+W) and removes SwiftUI's Settings item,
whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
- **Cmd+V is handled by the paste monitor, not the menu.** The panel is a
`.nonactivatingPanel`, so it can be key while the app is not active, and menu
key equivalents are not reliably delivered in that state. The monitor
dispatches `paste:` to the responder chain itself for text, and adds the
clipboard image to the store when there is no text. `Prompt --diag` dumps the
menu so the wiring can be checked without a keyboard.
- **A row is not a `Button`** because it contains a checkbox `Button`, and
nested buttons never receive clicks. The row uses `contentShape` +
`onTapGesture` so a click anywhere on the card selects it. Selection is
driven by the card click, the checkbox, Cmd+click (toggle) and Shift+click
(range); the context menu acts on the whole selection via `effectiveIDs(for:)`.
Copying is an explicit action (selection bar or context menu), never a side
effect of a click — that was changed deliberately.
- **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete,
plain Cmd+C). SwiftUI key equivalents are matched before text input and
would steal characters from the composer TextField. Only Cmd-modified
+14 -9
View File
@@ -2,17 +2,22 @@
A native macOS app for keeping prompts and photos, in the spirit of
[Copper](https://shadcn.com/copper) by shadcn: a floating panel that sits next
to your work. Click anything to copy it back to your clipboard.
to your work. Select what you need and send it back to your clipboard.
- **Click to copy** — text notes go back as text, photos go back as images.
- **Click to select** — clicking a card selects it (Shift+click for a range),
and the selection bar copies a single item as-is, or the whole selection as a
numbered list or as plain newline-separated lines.
- **Text or photo** — copying sends text back as text and a photo back as an
image.
- **Capture from anywhere** — select text in any app and tap **Left Shift twice**.
- **Photos** — paste (Cmd+V), drag & drop, or add via the photo button.
- **Sections** — type `# Name` in the composer; Cmd+K switches sections.
- **Copy as List / Copy as Lines** — Cmd+click to multi-select (Shift+click for
a range), then copy the selection as a numbered list or as plain
newline-separated text, from the selection bar or the right-click menu.
- **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
- **Search** — Cmd+F searches across all sections.
- **Workspaces** — type `# Name` in the composer, or use "New Workspace" in
the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the
up/down arrows in the switcher reorder them. (The code and the saved JSON
still call these `sections` so existing data keeps working.)
- **Right-click for actions** — Copy, Copy as List, Copy as Lines, Mark as Done,
Edit, Open, Merge Notes, Move To, Delete — each acting on the whole selection.
- **Search** — Cmd+F searches across all workspaces.
- **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and
enter it on the other; no account, no login. The code is the only secret.
- **Local & private** — everything lives in
@@ -25,7 +30,7 @@ to your work. Click anything to copy it back to your clipboard.
| --- | --- |
| Capture current selection | Left Shift ×2 (or Cmd+Shift+C) |
| Show/hide panel | Right Shift ×2 (or Cmd+Shift+Space) |
| Switch/create section | Cmd+K |
| Switch/create workspace | Cmd+K |
| Search | Cmd+F |
| Focus composer | Cmd+N |
| Sync settings | Cmd+, |
+27 -19
View File
@@ -32,6 +32,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// which locks the user out once the panel is hidden.
if CommandLine.arguments.contains("--diag") {
setupStatusItem()
installMenuItems()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.writeDiagnostics()
NSApp.terminate(nil)
@@ -59,9 +60,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
HotkeyManager.shared.start()
setupStatusItem()
setupMainMenu()
promptForAccessibilityIfNeeded()
// Let SwiftUI finish installing its main menu, then augment it.
DispatchQueue.main.async { self.installMenuItems() }
// Show the panel on launch so the app is discoverable (it has no Dock
// icon and the menu bar icon can be hard to spot on a full bar).
Task { @MainActor in
@@ -110,28 +113,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return .terminateNow
}
/// Accessory apps have no visible menu bar, but the main menu still
/// services key equivalents — this is what makes Cmd+Q and Cmd+W work
/// while the panel has focus.
private func setupMainMenu() {
let mainMenu = NSMenu()
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
/// Adds "Hide Panel" to the menu SwiftUI already installed. Do NOT replace
/// `NSApp.mainMenu`: SwiftUI's menu carries the Edit items that AppKit
/// routes Cmd+C/V/X/A/Z through, so replacing it breaks text editing in the
/// composer. This only inserts one item and drops SwiftUI's Settings item,
/// whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
private func installMenuItems() {
guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return }
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
if let settings = appMenu.items.firstIndex(where: { $0.keyEquivalent == "," && $0.keyEquivalentModifierMask == [.command] }) {
appMenu.removeItem(at: settings)
}
guard !appMenu.items.contains(where: { $0.action == #selector(hidePanel) }) else { return }
let hide = NSMenuItem(title: "Hide Panel", action: #selector(hidePanel), keyEquivalent: "w")
hide.target = self
appMenu.addItem(hide)
appMenu.addItem(.separator())
let quit = NSMenuItem(title: "Quit Prompt", action: #selector(quit), keyEquivalent: "q")
quit.target = self
appMenu.addItem(quit)
NSApp.mainMenu = mainMenu
let quitIndex = appMenu.items.firstIndex { $0.action == #selector(NSApplication.terminate(_:)) } ?? appMenu.items.count
appMenu.insertItem(.separator(), at: quitIndex)
appMenu.insertItem(hide, at: quitIndex + 1)
}
@MainActor @objc private func hidePanel() {
@@ -153,6 +152,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
lines.append("status item has no window (not in the menu bar)")
}
lines.append("preferred position: \(UserDefaults.standard.object(forKey: "NSStatusItem Preferred Position Prompt") ?? "unset")")
lines.append("main menu:")
for item in NSApp.mainMenu?.items ?? [] {
lines.append(" \(item.submenu?.title ?? item.title)")
for entry in item.submenu?.items ?? [] {
guard !entry.isSeparatorItem else { continue }
let action = entry.action.map(NSStringFromSelector) ?? "-"
lines.append(" \(entry.title) [\(entry.keyEquivalentModifierMask.rawValue)] \(action)")
}
}
try? lines.joined(separator: "\n").write(
to: URL(fileURLWithPath: "/tmp/prompt-diag.txt"), atomically: true, encoding: .utf8
)
+16 -6
View File
@@ -56,6 +56,10 @@ final class PanelController: NSObject {
panel.isMovableByWindowBackground = true
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.isOpaque = true
// Match the SwiftUI content so the titlebar strip and any resize flash
// do not show the system grey through.
panel.backgroundColor = Theme.background
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.contentView = NSHostingView(rootView: PanelView(store: Store.shared))
panel.setFrameAutosaveName("PromptPanel")
@@ -71,22 +75,28 @@ final class PanelController: NSObject {
panel.setFrameOrigin(NSPoint(x: screen.maxX - size.width - 12, y: screen.minY + 12))
}
// Intercept Cmd+V when the panel is key and the clipboard holds an image
// (and no text, so normal text paste into the composer is unaffected).
// Cmd+V is handled here rather than left to a menu key equivalent. The
// panel is a nonactivating panel, so it can be key while the app is not
// active, and menu key equivalents are not reliably delivered in that
// state — which is what silently broke pasting. Dispatching paste: to the
// responder chain ourselves removes that dependency.
private func installPasteMonitor() {
guard pasteMonitor == nil else { return }
pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, let panel = self.panel, event.window == panel else { return event }
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if modifiers == [.command], event.charactersIgnoringModifiers == "v" {
guard modifiers == [.command], event.charactersIgnoringModifiers == "v" else { return event }
let pasteboard = NSPasteboard.general
if pasteboard.string(forType: .string) == nil,
let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage],
if pasteboard.string(forType: .string) != nil {
NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
return nil
}
if let images = pasteboard.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage],
let image = images.first {
Task { @MainActor in Store.shared.addImage(image) }
return nil
}
}
return event
}
}
+143 -45
View File
@@ -10,6 +10,8 @@ struct PanelView: View {
@State private var search = ""
@State private var showSearch = false
@State private var showSectionSwitcher = false
@State private var showNewWorkspace = false
@State private var newWorkspaceName = ""
@State private var showSync = false
@State private var joinCode = ""
@State private var selection = Set<UUID>()
@@ -17,7 +19,7 @@ struct PanelView: View {
@State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted()
private enum Field: Hashable { case composer, search }
private enum Field: Hashable { case composer, search, newWorkspace }
@FocusState private var focusedField: Field?
var body: some View {
@@ -38,9 +40,11 @@ struct PanelView: View {
composerField
}
.frame(minWidth: 300)
.background(Theme.backgroundSwiftUI)
.background(shortcutButtons)
.overlay(sectionSwitcher)
.overlay(syncOverlay)
.overlay(newWorkspaceModal)
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
focusedField = .composer
@@ -66,7 +70,7 @@ struct PanelView: View {
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Switch section (Cmd+K)")
.help("Switch workspace (Cmd+K)")
Spacer()
@@ -113,7 +117,7 @@ struct PanelView: View {
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Search all sections", text: $search)
TextField("Search all workspaces", text: $search)
.textFieldStyle(.plain)
.focused($focusedField, equals: .search)
.onExitCommand {
@@ -190,8 +194,21 @@ struct PanelView: View {
.padding(.vertical, 48)
}
/// Rows carry a checkbox that *selects* the item, so a selection can be
/// copied or deleted from the context menu. The row cannot be a Button
/// because the checkbox is one, and nested buttons do not receive clicks.
private func row(for item: PromptItem, sectionName: String) -> some View {
Button { handleClick(item) } label: {
HStack(alignment: .top, spacing: 8) {
Button {
toggleSelection(item.id)
} label: {
Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 15))
.foregroundStyle(selection.contains(item.id) ? Color.accentColor : Color.secondary.opacity(0.55))
}
.buttonStyle(.plain)
.help(selection.contains(item.id) ? "Deselect" : "Select for copy or delete")
VStack(alignment: .leading, spacing: 6) {
if !sectionName.isEmpty {
Text(sectionName.uppercased())
@@ -205,13 +222,12 @@ struct PanelView: View {
Text(sourceApp)
}
Text(item.createdAt, style: .time)
if item.done {
Image(systemName: "checkmark")
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(rowBackground(item))
@@ -225,15 +241,15 @@ struct PanelView: View {
.transition(.opacity)
}
}
}
.buttonStyle(.plain)
.help("Click to copy · Cmd+click to select several · Shift+click for a range · right-click for more")
.contentShape(Rectangle())
.onTapGesture { handleClick(item) }
.help("Click to select · Shift+click for a range · right-click to copy, delete and more")
.contextMenu { contextMenu(for: item) }
}
private func rowBackground(_ item: PromptItem) -> Color {
if selection.contains(item.id) { return Color.accentColor.opacity(0.25) }
return Color.primary.opacity(0.05)
return Theme.rowSwiftUI
}
@ViewBuilder
@@ -263,43 +279,51 @@ struct PanelView: View {
}
private func contextMenu(for item: PromptItem) -> some View {
Group {
let ids = effectiveIDs(for: item)
let multiple = ids.count > 1
return Group {
Button("Copy") { copy(item) }
Button("Copy as List") {
store.copyAsList(ids: effectiveIDs(for: item))
Button(multiple ? "Copy \(ids.count) as List" : "Copy as List") {
store.copyAsList(ids: ids)
flashCopied(item.id)
}
Button("Copy as Lines") {
store.copyAsLines(ids: effectiveIDs(for: item))
Button(multiple ? "Copy \(ids.count) as Lines" : "Copy as Lines") {
store.copyAsLines(ids: ids)
flashCopied(item.id)
}
Divider()
Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) }
if case .text(let text) = item.content {
Button(item.done ? "Mark as Not Done" : "Mark as Done") {
store.setDone(!item.done, ids: ids)
}
if case .text(let text) = item.content, !multiple {
Button("Edit…") {
PanelController.shared.editController.edit(itemID: item.id, text: text)
}
}
if case .image(let filename) = item.content {
if case .image(let filename) = item.content, !multiple {
Button("Open") {
NSWorkspace.shared.open(store.imageURL(for: filename))
}
}
if selection.count > 1, selection.contains(item.id) {
if multiple {
Button("Merge Notes") {
store.merge(ids: selection)
store.merge(ids: ids)
clearSelection()
}
}
Menu("Move To") {
ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in
Button(section.name) { store.move(item.id, to: section.id) }
Button(section.name) {
store.move(ids: ids, to: section.id)
if multiple { clearSelection() }
}
}
}
Divider()
Button("Delete", role: .destructive) {
selection.remove(item.id)
store.delete(item.id)
Button(multiple ? "Delete \(ids.count) Items" : "Delete", role: .destructive) {
store.delete(ids: ids)
selection.subtract(ids)
lastSelectedID = nil
}
}
}
@@ -315,12 +339,8 @@ struct PanelView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 0) {
ForEach(store.sections) { section in
Button {
store.activeSectionID = section.id
showSectionSwitcher = false
} label: {
HStack {
ForEach(Array(store.sections.enumerated()), id: \.element.id) { index, section in
HStack(spacing: 6) {
Text(section.name)
.font(.callout)
Spacer()
@@ -332,22 +352,44 @@ struct PanelView: View {
.font(.caption)
.foregroundStyle(Color.accentColor)
}
// Reorder the workspace list; the boundary rows are
// disabled rather than hidden so the list does not jump.
HStack(spacing: 0) {
Button {
store.moveSection(section.id, by: -1)
} label: {
Image(systemName: "chevron.up")
}
.disabled(index == 0)
Button {
store.moveSection(section.id, by: 1)
} label: {
Image(systemName: "chevron.down")
}
.disabled(index == store.sections.count - 1)
}
.buttonStyle(.plain)
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture {
store.activeSectionID = section.id
showSectionSwitcher = false
}
.buttonStyle(.plain)
}
Divider()
Button {
composer = "# "
showSectionSwitcher = false
focusedField = .composer
newWorkspaceName = ""
showNewWorkspace = true
focusedField = .newWorkspace
} label: {
HStack {
Image(systemName: "plus")
Text("New Section")
Text("New Workspace")
Spacer()
}
.padding(.horizontal, 14)
@@ -465,6 +507,53 @@ struct PanelView: View {
}
}
// MARK: - New workspace
@ViewBuilder
private var newWorkspaceModal: some View {
if showNewWorkspace {
ZStack {
Color.black.opacity(0.25)
.onTapGesture { dismissNewWorkspace() }
VStack(alignment: .leading, spacing: 12) {
Text("New Workspace")
.font(.headline)
TextField("Name", text: $newWorkspaceName)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .newWorkspace)
.onSubmit(createWorkspace)
.onExitCommand { dismissNewWorkspace() }
HStack {
Spacer()
Button("Cancel") { dismissNewWorkspace() }
Button("Create") { createWorkspace() }
.keyboardShortcut(.defaultAction)
.disabled(newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(16)
.frame(width: 260)
.background(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 18)
}
.transition(.opacity)
}
}
private func createWorkspace() {
let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
store.createSection(named: name)
dismissNewWorkspace()
}
private func dismissNewWorkspace() {
showNewWorkspace = false
newWorkspaceName = ""
focusedField = .composer
}
// MARK: - Selection
private var selectionBar: some View {
@@ -473,6 +562,12 @@ struct PanelView: View {
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if selection.count == 1, let id = selection.first, let item = store.item(id) {
Button("Copy") { copy(item) }
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
}
Button("Copy as List") { copySelection(asList: true) }
.font(.caption)
.buttonStyle(.plain)
@@ -581,22 +676,25 @@ struct PanelView: View {
// MARK: - Actions
/// A click anywhere on the card selects it (Shift+click selects a range).
/// Copying is then an action on the selection — from the selection bar or
/// the right-click menu — rather than the side effect of a click.
private func handleClick(_ item: PromptItem) {
let modifiers = NSApp.currentEvent?.modifierFlags ?? []
if modifiers.contains(.command) {
if selection.contains(item.id) {
selection.remove(item.id)
} else {
selection.insert(item.id)
}
lastSelectedID = item.id
return
}
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
selectRange(from: anchor, to: item.id)
return
}
copy(item)
toggleSelection(item.id)
}
private func toggleSelection(_ id: UUID) {
if selection.contains(id) {
selection.remove(id)
} else {
selection.insert(id)
}
lastSelectedID = id
}
private func selectRange(from anchor: UUID, to target: UUID) {
+57 -8
View File
@@ -38,11 +38,17 @@ final class Store: ObservableObject {
private let storeFile: URL
private let imageCache = NSCache<NSString, NSImage>()
init() {
/// `directory` is injectable so the store can be exercised against a
/// scratch folder instead of the user's real data.
init(directory: URL? = nil) {
if let directory {
self.directory = directory
} else {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
directory = appSupport.appendingPathComponent("Prompt", isDirectory: true)
imagesDirectory = directory.appendingPathComponent("Images", isDirectory: true)
storeFile = directory.appendingPathComponent("store.json")
self.directory = appSupport.appendingPathComponent("Prompt", isDirectory: true)
}
imagesDirectory = self.directory.appendingPathComponent("Images", isDirectory: true)
storeFile = self.directory.appendingPathComponent("store.json")
try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
load()
@@ -130,6 +136,18 @@ final class Store: ObservableObject {
return section
}
/// Reorders a workspace within the list. The UI calls these "workspaces";
/// the model and the persisted JSON keep the original `sections` naming so
/// existing stores and sync payloads keep working.
func moveSection(_ id: UUID, by offset: Int) {
guard let index = sections.firstIndex(where: { $0.id == id }) else { return }
let target = index + offset
guard sections.indices.contains(target) else { return }
let section = sections.remove(at: index)
sections.insert(section, at: target)
save()
}
// MARK: - Items
@discardableResult
@@ -172,18 +190,49 @@ final class Store: ObservableObject {
}
func delete(_ id: UUID) {
delete(ids: [id])
}
/// Deletes every item in `ids` in one pass, cleaning up their image files.
func delete(ids: Set<UUID>) {
guard !ids.isEmpty else { return }
var removedAny = false
for sectionIndex in sections.indices {
if let itemIndex = sections[sectionIndex].items.firstIndex(where: { $0.id == id }) {
let item = sections[sectionIndex].items[itemIndex]
for item in sections[sectionIndex].items where ids.contains(item.id) {
removedAny = true
if case .image(let filename) = item.content {
try? FileManager.default.removeItem(at: imagesDirectory.appendingPathComponent(filename))
imageCache.removeObject(forKey: filename as NSString)
}
sections[sectionIndex].items.remove(at: itemIndex)
}
sections[sectionIndex].items.removeAll { ids.contains($0.id) }
}
if removedAny { save() }
}
func setDone(_ done: Bool, ids: Set<UUID>) {
guard !ids.isEmpty else { return }
for sectionIndex in sections.indices {
for itemIndex in sections[sectionIndex].items.indices
where ids.contains(sections[sectionIndex].items[itemIndex].id) {
sections[sectionIndex].items[itemIndex].done = done
}
}
save()
return
}
/// Moves every item in `ids` into `targetSectionID`, keeping their order.
func move(ids: Set<UUID>, to targetSectionID: UUID) {
guard !ids.isEmpty,
let targetIndex = sections.firstIndex(where: { $0.id == targetSectionID }) else { return }
var moved: [PromptItem] = []
for sectionIndex in sections.indices where sectionIndex != targetIndex {
moved.append(contentsOf: sections[sectionIndex].items.filter { ids.contains($0.id) })
sections[sectionIndex].items.removeAll { ids.contains($0.id) }
}
guard !moved.isEmpty else { return }
sections[targetIndex].items.append(contentsOf: moved)
save()
}
func move(_ id: UUID, to targetSectionID: UUID) {
+28
View File
@@ -0,0 +1,28 @@
import AppKit
import SwiftUI
/// Appearance-aware colours for the panel.
///
/// Dark mode is pure black rather than the system window grey: the panel is a
/// full-height strip that sits beside your work, and the grey reads as a
/// half-lit window next to a dark editor or terminal.
enum Theme {
static func isDark(_ appearance: NSAppearance) -> Bool {
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
/// The panel's own background (also handed to the NSPanel so the area under
/// the transparent titlebar matches).
static let background = NSColor(name: nil) { appearance in
isDark(appearance) ? .black : .windowBackgroundColor
}
/// Card background, kept subtle but visible against pure black.
static let row = NSColor(name: nil) { appearance in
isDark(appearance) ? NSColor(white: 1, alpha: 0.07) : NSColor(white: 0, alpha: 0.05)
}
/// Same colours for SwiftUI.
static let backgroundSwiftUI = Color(nsColor: background)
static let rowSwiftUI = Color(nsColor: row)
}