diff --git a/PLAN.md b/PLAN.md index c8e60ad..1de8fb4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -128,6 +128,26 @@ correctly instead of needing special cases. The other half of the product, and the reason reordering exists: collect a scattered set of values, arrange them, and put them back into one document in the right order. +### Choosing which clips are in play + +A spool is not always wanted whole. Ticking clips narrows what the next serve delivers and what the +whole-spool paste joins — the same working set for both, so `Win+Alt+U`, `Win+Alt+V` and the button +can never mean different things. + +**An empty selection means every clip.** Selecting nothing and meaning nothing is not a state worth +having: it would make both hotkeys dead and the button a no-op, with nothing to distinguish that +from a bug. So clearing the selection and selecting everything are the same act, and the app offers +one command rather than two that disagree at the edges. Unticking the last box returns to all. + +The selection is **not stored**. It describes what you are doing now, the way a text selection does, +and one that survived a restart would be a rule the user does not remember making. It ends when the +active spool changes, when the spool is cleared, and it drops any clip that is deleted — a set still +naming a clip that is gone would make the button promise more than it can deliver. + +Serving steps over what is not in play, in both directions, and wraps among the chosen clips. A +cursor sitting on an excluded clip is not an error: it was put there before the choice was made, so +the next serve walks forward to the first clip that is in play rather than refusing. + **It joins and writes once.** Every clip in the spool is concatenated with a separator and written to the system clipboard as a single item. The user then pastes normally, once. The alternative — synthesising one paste per clip — is rejected for the same reason serve-and-paste is (§8): it needs diff --git a/src/main/core/join.ts b/src/main/core/join.ts index a52c597..4931bef 100644 --- a/src/main/core/join.ts +++ b/src/main/core/join.ts @@ -1,3 +1,4 @@ +import { selectedClips } from './selection' import type { Spool } from './types' /** @@ -66,10 +67,16 @@ export type JoinResult = * would silently drop the clips behind it. The cursor does not move: this is a bulk read, not a * traversal. */ -export function joinSpool(spool: Spool, separator: SeparatorKind): JoinResult { - if (spool.clips.length === 0) return { ok: false, reason: 'empty' } +export function joinSpool( + spool: Spool, + separator: SeparatorKind, + selection: ReadonlySet = new Set() +): JoinResult { + // A selection narrows what is joined; empty means every clip, so the ordinary case is unchanged. + const chosen = selectedClips(spool.clips, selection) + if (chosen.length === 0) return { ok: false, reason: 'empty' } - const ordered = spool.mode === 'fifo' ? spool.clips : [...spool.clips].reverse() + const ordered = spool.mode === 'fifo' ? chosen : [...chosen].reverse() const text = ordered.map((clip) => clip.content).join(separatorText(separator)) return { diff --git a/src/main/core/selection.test.ts b/src/main/core/selection.test.ts new file mode 100644 index 0000000..f89f3a4 --- /dev/null +++ b/src/main/core/selection.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { isSelected, prune, selectedClips, selectedCount, toggle } from './selection' +import type { Clip } from './types' + +const clip = (id: string): Clip => ({ + id, + content: id, + preview: id, + byteLength: id.length, + sourceApp: null, + wasFlagged: false, + capturedAt: '2026-09-11T00:00:00.000Z' +}) + +const clips = [clip('a'), clip('b'), clip('c')] + +describe('an empty selection means every clip (PLAN.md 3)', () => { + // The rule the whole feature rests on: selecting nothing and meaning nothing is not a state worth + // having, because it would make the hotkeys dead with no way to tell that apart from a bug. + it('treats every clip as in play', () => { + const none = new Set() + expect(clips.every((c) => isSelected(c.id, none))).toBe(true) + expect(selectedClips(clips, none)).toEqual(clips) + expect(selectedCount(clips, none)).toBe(3) + }) + + it('narrows to exactly what was chosen once something is', () => { + const some = new Set(['a', 'c']) + expect(selectedClips(clips, some).map((c) => c.id)).toEqual(['a', 'c']) + expect(selectedCount(clips, some)).toBe(2) + expect(isSelected('b', some)).toBe(false) + }) + + it('keeps spool order rather than the order things were ticked', () => { + expect(selectedClips(clips, new Set(['c', 'a'])).map((c) => c.id)).toEqual(['a', 'c']) + }) +}) + +describe('toggle', () => { + it('adds and removes one clip', () => { + expect([...toggle(new Set(), 'b')]).toEqual(['b']) + expect([...toggle(new Set(['b']), 'b')]).toEqual([]) + }) + + // Unticking the last box returns to all, which is the same state as never having ticked one. + // Clearing the selection and selecting everything are one act, so they cannot disagree. + it('emptying the selection is the same as selecting all', () => { + const emptied = toggle(new Set(['b']), 'b') + expect(selectedCount(clips, emptied)).toBe(3) + }) +}) + +describe('prune', () => { + it('drops ids for clips that are gone', () => { + expect([...prune([clip('a')], new Set(['a', 'b']))]).toEqual(['a']) + }) + + // A selection still holding a deleted clip would make the button promise more than it can give. + it('a selection emptied by deletion means all again, not none', () => { + expect(selectedCount([clip('a')], prune([clip('a')], new Set(['gone'])))).toBe(1) + }) +}) diff --git a/src/main/core/selection.ts b/src/main/core/selection.ts new file mode 100644 index 0000000..4bdb41d --- /dev/null +++ b/src/main/core/selection.ts @@ -0,0 +1,51 @@ +import type { Clip } from './types' + +/** + * Which clips are in play (PLAN.md 3). + * + * A selection is a working set, not a property of the clips: it says which of them the next serve, + * the next whole-spool paste, and the button that offers it should consider. It is deliberately not + * stored — it is about what you are doing right now, the way a text selection is, and a selection + * that survived a restart would be a rule the user does not remember making. + * + * **An empty selection means every clip.** Selecting nothing and meaning nothing is not a state + * worth having: it would make the hotkeys dead and the button a no-op, with no way to tell that + * apart from a bug. So clearing the selection and selecting everything are the same act, and the + * app says so rather than offering both. + */ +export function isSelected(clipId: string, selection: ReadonlySet): boolean { + return selection.size === 0 || selection.has(clipId) +} + +/** The clips a serve or a join should consider, in spool order. */ +export function selectedClips( + clips: readonly Clip[], + selection: ReadonlySet +): readonly Clip[] { + return selection.size === 0 ? clips : clips.filter((clip) => selection.has(clip.id)) +} + +/** How many clips are in play, which is what the button has to name. */ +export function selectedCount(clips: readonly Clip[], selection: ReadonlySet): number { + return selectedClips(clips, selection).length +} + +/** + * Drop ids that are no longer in the spool. + * + * A selection holding a deleted clip would keep counting it, so the button would promise more than + * it could deliver — and an id that came back on a later clip would silently select something the + * user never chose. + */ +export function prune(clips: readonly Clip[], selection: ReadonlySet): Set { + const present = new Set(clips.map((clip) => clip.id)) + return new Set([...selection].filter((id) => present.has(id))) +} + +/** Add or remove one clip. Removing the last one empties the selection, which means all again. */ +export function toggle(selection: ReadonlySet, clipId: string): Set { + const next = new Set(selection) + if (next.has(clipId)) next.delete(clipId) + else next.add(clipId) + return next +} diff --git a/src/main/core/spool.ts b/src/main/core/spool.ts index 4cbbd6d..eb07b07 100644 --- a/src/main/core/spool.ts +++ b/src/main/core/spool.ts @@ -1,5 +1,6 @@ import { byteLength } from './clip' import { CLIP_BYTE_CAP, DEFAULT_SPOOL_CLIP_CAP, SAVED_SPOOL_CLIP_CAP } from './limits' +import { isSelected } from './selection' import type { CaptureResult, Clip, Mode, ServeResult, Spool, SpoolKind } from './types' /** The clip cap this spool is bound by (PLAN.md 3, Limits). */ @@ -100,14 +101,37 @@ export function capture(spool: Spool, clip: Clip): CaptureResult { * Write the cursor's clip out and advance (PLAN.md 3). **Serving pastes; it does not pop** — the * clip stays exactly where it was, and the cursor moves one step in the mode's direction, wrapping * at the end. + * + * A selection narrows what is in play: unselected clips are stepped over, so unspooling walks the + * chosen ones in the mode's order and wraps among them. An empty selection means every clip, so + * the ordinary case costs nothing. */ -export function serve(spool: Spool): ServeResult { - const index = cursorIndex(spool) - if (index === -1) return { ok: false, reason: 'empty', spool } +export function serve(spool: Spool, selection: ReadonlySet = new Set()): ServeResult { + const count = spool.clips.length + if (count === 0) return { ok: false, reason: 'empty', spool } + + const eligible = (clip: Clip): boolean => isSelected(clip.id, selection) + if (!spool.clips.some(eligible)) return { ok: false, reason: 'empty', spool } + + const start = cursorIndex(spool) + if (start === -1) return { ok: false, reason: 'empty', spool } + const direction = step(spool.mode) + + // The cursor may be sitting on a clip the selection excludes — it was put there before the + // selection was made. Walk to the first one that is in play rather than refusing to serve. + const nextEligible = (from: number): number => { + let at = from + for (let taken = 0; taken < count; taken++) { + if (eligible(spool.clips[at])) return at + at = (at + direction + count) % count + } + return from + } + + const index = nextEligible(start) const clip = spool.clips[index] - const count = spool.clips.length - const next = (index + step(spool.mode) + count) % count + const next = nextEligible((index + direction + count) % count) return { ok: true, clip, spool: { ...spool, cursorClipId: spool.clips[next].id } } } diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 6058829..e62fbb0 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -85,6 +85,10 @@ export function registerIpc( ipcMain.handle(CHANNELS.acknowledgePrivacy, () => actions.acknowledgePrivacy()) ipcMain.handle(CHANNELS.toggleMode, () => session.toggleMode()) ipcMain.handle(CHANNELS.setAutoPaste, (_event, enabled: boolean) => session.setAutoPaste(enabled)) + ipcMain.handle(CHANNELS.toggleClipSelected, (_event, clipId: string) => + session.toggleClipSelected(clipId) + ) + ipcMain.handle(CHANNELS.selectAllClips, () => session.selectAllClips()) ipcMain.handle(CHANNELS.setHotkey, (_event, action: HotkeyAction, accelerator: string) => actions.setHotkey(action, accelerator) ) @@ -129,6 +133,8 @@ export function registerIpc( ipcMain.removeHandler(CHANNELS.acknowledgePrivacy) ipcMain.removeHandler(CHANNELS.toggleMode) ipcMain.removeHandler(CHANNELS.setAutoPaste) + ipcMain.removeHandler(CHANNELS.toggleClipSelected) + ipcMain.removeHandler(CHANNELS.selectAllClips) ipcMain.removeHandler(CHANNELS.setHotkey) ipcMain.removeHandler(CHANNELS.resetHotkey) ipcMain.removeHandler(CHANNELS.resumeCapture) diff --git a/src/main/ipc/view.ts b/src/main/ipc/view.ts index 3a5751c..6d25f1b 100644 --- a/src/main/ipc/view.ts +++ b/src/main/ipc/view.ts @@ -1,3 +1,4 @@ +import { isSelected, selectedCount } from '../core/selection' import { clipCap } from '../core/spool' import type { Spool } from '../core/types' import type { SpoolView } from '../../shared/ipc' @@ -8,7 +9,10 @@ import type { SpoolView } from '../../shared/ipc' * Clip **content** deliberately does not cross: the compact window shows previews, and the full * text has no business in a renderer that only displays it. Pure, so it is tested without a window. */ -export function toSpoolView(spool: Spool): SpoolView { +export function toSpoolView( + spool: Spool, + selection: ReadonlySet = new Set() +): SpoolView { return { name: spool.name, mode: spool.mode, @@ -16,10 +20,17 @@ export function toSpoolView(spool: Spool): SpoolView { id: clip.id, preview: clip.preview, capturedAt: clip.capturedAt, - sourceApp: clip.sourceApp + sourceApp: clip.sourceApp, + isSelected: isSelected(clip.id, selection) })), cursorClipId: spool.cursorClipId, count: spool.clips.length, - cap: clipCap(spool.kind) + cap: clipCap(spool.kind), + // What a serve or a whole-spool paste would act on. Equal to `count` when nothing is chosen, + // because an empty selection means every clip. + inPlay: selectedCount(spool.clips, selection), + // Whether the user has actually chosen a subset, which is what the UI needs to know to offer + // a way back to all of them. + hasSelection: selection.size > 0 } } diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 465cbdb..436a968 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -780,6 +780,115 @@ describe('pasting the whole spool (PLAN.md 3)', () => { }) }) +describe('choosing which clips are in play (PLAN.md 3)', () => { + function withClips(...contents: string[]) { + const { session, watcher, written } = started((report) => report(true)) + for (const content of contents) watcher.change(text(content)) + return { session, watcher, written } + } + + const ids = (session: Session): string[] => session.getState().spool.clips.map((c) => c.id) + + it('unspools every clip when nothing has been chosen', () => { + const { session, written } = withClips('one', 'two', 'three') + + session.serveNext() + session.serveNext() + + expect(written).toEqual(['one', 'two']) + expect(session.getState().spool.inPlay).toBe(3) + expect(session.getState().spool.hasSelection).toBe(false) + }) + + it('steps over the clips that were not chosen', () => { + const { session, written } = withClips('one', 'two', 'three') + const [first, , third] = ids(session) + session.toggleClipSelected(first) + session.toggleClipSelected(third) + + session.serveNext() + session.serveNext() + + expect(written).toEqual(['one', 'three']) + }) + + it('wraps among the chosen clips rather than through the others', () => { + const { session, written } = withClips('one', 'two', 'three') + const [first, , third] = ids(session) + session.toggleClipSelected(first) + session.toggleClipSelected(third) + + session.serveNext() + session.serveNext() + session.serveNext() + + expect(written).toEqual(['one', 'three', 'one']) + }) + + it('joins only the chosen clips, in spool order', () => { + const { session, written } = withClips('one', 'two', 'three') + const [first, , third] = ids(session) + session.toggleClipSelected(third) + session.toggleClipSelected(first) + + session.pasteWholeSpool() + + expect(written).toEqual(['one\nthree']) + expect(session.getState().spool.inPlay).toBe(2) + expect(session.getState().spool.hasSelection).toBe(true) + }) + + it('serves a chosen clip even when the cursor sat on one that was not', () => { + const { session, written } = withClips('one', 'two', 'three') + // The cursor is on 'one'; choosing only the third must not leave serving stuck. + session.toggleClipSelected(ids(session)[2]) + + session.serveNext() + + expect(written).toEqual(['three']) + }) + + it('unticking the last clip returns to all, rather than to none', () => { + const { session, written } = withClips('one', 'two') + const [first] = ids(session) + session.toggleClipSelected(first) + expect(session.getState().spool.inPlay).toBe(1) + + session.toggleClipSelected(first) + + expect(session.getState().spool.hasSelection).toBe(false) + session.pasteWholeSpool() + expect(written).toEqual(['one\ntwo']) + }) + + it('selecting all again is the way back, and costs nothing when already there', () => { + const { session } = withClips('one', 'two') + session.toggleClipSelected(ids(session)[0]) + + session.selectAllClips() + + expect(session.getState().spool.hasSelection).toBe(false) + expect(session.getState().spool.inPlay).toBe(2) + }) + + it('forgets the choice when the clips it named are deleted', () => { + const { session } = withClips('one', 'two') + const [first] = ids(session) + session.toggleClipSelected(first) + + session.deleteClip(first) + + // The chosen clip is gone, so the set is empty — which means all of what is left. + expect(session.getState().spool.hasSelection).toBe(false) + expect(session.getState().spool.inPlay).toBe(1) + }) + + it('marks every clip as in play in the view when nothing is chosen', () => { + const { session } = withClips('one', 'two') + expect(session.getState().spool.clips.every((c) => c.isSelected)).toBe(true) + }) +}) + describe('arranging (PLAN.md 11, M7)', () => { it('applies an arrangement to the active spool', () => { const { session, watcher } = started() diff --git a/src/main/session.ts b/src/main/session.ts index e34883a..1496e54 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -35,6 +35,7 @@ import { type MeasureName } from './core/capacity' import { expireClips, isRetentionHours } from './core/retention' +import { prune, toggle } from './core/selection' import { arrange, clear, createSpool, deleteClip, serve, setMode } from './core/spool' import type { Clip, Mode, Spool } from './core/types' import type { ClipboardSnapshot } from './detect/admit' @@ -147,6 +148,16 @@ export class Session { */ private autoPaste = true + /** + * Which clips are in play (PLAN.md 3). Empty means every clip, and is the state the app starts + * in — a selection nobody has made is not a selection of nothing. + * + * Not stored, and cleared when the active spool changes. It describes what you are doing now, the + * way a text selection does; one that survived a restart would be a rule the user does not + * remember making. + */ + private selection: ReadonlySet = new Set() + constructor( private readonly writeText: (text: string) => void, /** @@ -176,6 +187,22 @@ export class Session { this.publish() } + /** Put one clip in or out of the working set. */ + toggleClipSelected(clipId: string): void { + this.selection = toggle(this.selection, clipId) + this.publish() + } + + /** + * Back to every clip. Clearing the selection and selecting everything are the same act, so there + * is one command rather than two that quietly disagree at the edges. + */ + selectAllClips(): void { + if (this.selection.size === 0) return + this.selection = new Set() + this.publish() + } + setAutoPaste(enabled: boolean): void { this.autoPaste = enabled this.publish() @@ -370,7 +397,7 @@ export class Session { * pasted as many times as the user likes. */ serveNext(): void { - const result = serve(this.state.spool) + const result = serve(this.state.spool, this.selection) if (!result.ok) { this.notice = NOTHING_TO_PASTE @@ -406,7 +433,7 @@ export class Session { * application on the machine. */ pasteWholeSpool(confirmed = false): void { - const joined = joinSpool(this.state.spool, this.settings.separator) + const joined = joinSpool(this.state.spool, this.settings.separator, this.selection) if (!joined.ok) { this.notice = NOTHING_TO_PASTE @@ -592,6 +619,9 @@ export class Session { */ deleteClip(clipId: string): void { this.state = { ...this.state, spool: deleteClip(this.state.spool, clipId) } + // A selection holding a clip that is gone would keep counting it, so the button would promise + // more than it can deliver. + this.selection = prune(this.state.spool.clips, this.selection) this.publish() } @@ -601,6 +631,8 @@ export class Session { this.state = { ...this.state, spool: clear(this.state.spool) } // The next copy is not a duplicate of something that is no longer there. this.state = { ...this.state, lastCapturedText: null } + // Every clip the working set named has gone with them. + this.selection = new Set() } else { this.otherSpools = this.otherSpools.map((spool) => spool.id === spoolId ? clear(spool) : spool @@ -834,6 +866,9 @@ export class Session { const remaining = this.otherSpools.filter((spool) => spool.id !== next.id) this.otherSpools = keepLeaving ? [...remaining, leaving] : remaining + // The working set named clips in the spool being left, so it ends with it. Carrying it across + // would leave a selection the user cannot see and did not make here. + this.selection = new Set() // Duplicate suppression compares against the last capture *in this spool*, so it resets. this.state = { ...this.state, spool: this.touch(next), lastCapturedText: null } this.savedSpool = null @@ -841,7 +876,7 @@ export class Session { getState(): AppState { return { - spool: toSpoolView(this.state.spool), + spool: toSpoolView(this.state.spool, this.selection), notice: this.notice, capture: this.capture, storage: this.storage, diff --git a/src/preload/index.ts b/src/preload/index.ts index 9c31d69..09d5dd1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -71,6 +71,13 @@ const api = { setAutoPaste: (enabled: boolean): Promise => ipcRenderer.invoke(CHANNELS.setAutoPaste, enabled), + /** Put one clip in or out of the working set (PLAN.md 3). */ + toggleClipSelected: (clipId: string): Promise => + ipcRenderer.invoke(CHANNELS.toggleClipSelected, clipId), + + /** Back to every clip. Clearing a selection and selecting all are the same act. */ + selectAllClips: (): Promise => ipcRenderer.invoke(CHANNELS.selectAllClips), + /** Change direction. On the mode pill rather than a hotkey (PLAN.md 8). */ toggleMode: (): Promise => ipcRenderer.invoke(CHANNELS.toggleMode), diff --git a/src/renderer/components/ClipList.tsx b/src/renderer/components/ClipList.tsx index ff2300a..fba77d5 100644 --- a/src/renderer/components/ClipList.tsx +++ b/src/renderer/components/ClipList.tsx @@ -38,6 +38,18 @@ export function ClipList({ spool }: { spool: SpoolView }): JSX.Element { } >
+ {/* + Checked means in play. With nothing chosen every box is checked, because an empty + selection means every clip — so the list never shows a state the hotkeys disagree + with (PLAN.md 3). + */} + void window.spool.toggleClipSelected(clip.id)} + aria-label={`Include ${clip.preview}`} + className="mt-0.5 shrink-0" + />
{source !== null && ( - {source} + {source} )} ) diff --git a/src/renderer/components/ExpandedView.tsx b/src/renderer/components/ExpandedView.tsx index a31770d..424f850 100644 --- a/src/renderer/components/ExpandedView.tsx +++ b/src/renderer/components/ExpandedView.tsx @@ -1,7 +1,7 @@ import { useState, type JSX } from 'react' import type { AppState, SeparatorKind } from '../../shared/ipc' import { hasChanged, sameClips } from '../helpers/ArrangeListHelper' -import { formatBytes, separatorOptions } from '../helpers/ExpandedViewHelper' +import { formatBytes, pasteAllLabel, separatorOptions } from '../helpers/ExpandedViewHelper' import { ArrangeList } from './ArrangeList' import { SpoolSidebar } from './SpoolSidebar' @@ -134,10 +134,21 @@ export function ExpandedView({ onClick={() => void window.spool.pasteWholeSpool()} className="w-full rounded border border-spool-thread/50 px-2 py-1.5 text-spool-thread hover:bg-spool-thread/10 disabled:border-spool-paper/10 disabled:text-spool-paper/25" > - Put all {spool.count} on the clipboard + {pasteAllLabel(spool)} + {spool.hasSelection && ( + + )}

- Then paste once, with Ctrl+V. The cursor does not move. + {spool.hasSelection + ? 'Ticked clips are the ones in play, for this button and for unspooling.' + : 'Then paste once, with Ctrl+V. The cursor does not move.'}

diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 4757508..3a639a1 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -27,6 +27,8 @@ declare global { pauseCapture(): Promise acknowledgePrivacy(): Promise toggleMode(): Promise + toggleClipSelected(clipId: string): Promise + selectAllClips(): Promise setAutoPaste(enabled: boolean): Promise setHotkey(action: HotkeyAction, accelerator: string): Promise resetHotkey(action: HotkeyAction): Promise diff --git a/src/renderer/helpers/ArrangeListHelper.test.ts b/src/renderer/helpers/ArrangeListHelper.test.ts index 1acb52d..d9b4f98 100644 --- a/src/renderer/helpers/ArrangeListHelper.test.ts +++ b/src/renderer/helpers/ArrangeListHelper.test.ts @@ -40,10 +40,10 @@ describe('hasChanged', () => { describe('sourceLabel', () => { it('drops the extension a Windows process name carries', () => { - expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: 'EXCEL.EXE' })).toBe( + expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: 'EXCEL.EXE', isSelected: true })).toBe( 'EXCEL' ) - expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: null })).toBeNull() + expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: null, isSelected: true })).toBeNull() }) }) diff --git a/src/renderer/helpers/ClipListHelper.test.ts b/src/renderer/helpers/ClipListHelper.test.ts index 5a07e9e..a283870 100644 --- a/src/renderer/helpers/ClipListHelper.test.ts +++ b/src/renderer/helpers/ClipListHelper.test.ts @@ -6,7 +6,8 @@ const clip = (id: string, sourceApp: string | null = null): ClipView => ({ id, preview: `preview ${id}`, capturedAt: '2026-08-22T07:00:00.000Z', - sourceApp + sourceApp, + isSelected: true }) const spool = (ids: string[], cursorClipId: string | null): SpoolView => ({ @@ -14,6 +15,8 @@ const spool = (ids: string[], cursorClipId: string | null): SpoolView => ({ mode: 'fifo', clips: ids.map((id) => clip(id)), cursorClipId, + inPlay: ids.length, + hasSelection: false, count: ids.length, cap: 50 }) diff --git a/src/renderer/helpers/ExpandedViewHelper.ts b/src/renderer/helpers/ExpandedViewHelper.ts index 2a5558f..bee09a5 100644 --- a/src/renderer/helpers/ExpandedViewHelper.ts +++ b/src/renderer/helpers/ExpandedViewHelper.ts @@ -1,4 +1,4 @@ -import type { SeparatorKind } from '../../shared/ipc' +import type { SeparatorKind, SpoolView } from '../../shared/ipc' /** Pure helpers for the expanded window. No React, no I/O (PLAN.md 6). */ @@ -26,3 +26,16 @@ export function formatBytes(bytes: number): string { if (kib >= 1) return `${Math.round(kib * 10) / 10} KB` return `${bytes} bytes` } + +/** + * What the whole-spool button says it will take (PLAN.md 3). + * + * It names the number in play rather than the number in the spool, because the button and the + * hotkey must agree: `Win+Alt+V` pastes exactly what this says. "All 15" when nothing is chosen, + * "3 of 15" when a subset is — the total stays visible so the selection is legible as a narrowing + * rather than as the whole truth. + */ +export function pasteAllLabel(spool: SpoolView): string { + if (!spool.hasSelection) return `Put all ${spool.count} on the clipboard` + return `Put ${spool.inPlay} of ${spool.count} on the clipboard` +} diff --git a/src/renderer/state/useAppState.ts b/src/renderer/state/useAppState.ts index 9e07e4e..21d5040 100644 --- a/src/renderer/state/useAppState.ts +++ b/src/renderer/state/useAppState.ts @@ -8,7 +8,9 @@ import type { AppState } from '../../shared/ipc' type Action = { type: 'state'; state: AppState } const initialState: AppState = { - spool: { name: 'Default spool', mode: 'fifo', clips: [], cursorClipId: null, count: 0, cap: 50 }, + spool: { name: 'Default spool', mode: 'fifo', clips: [], cursorClipId: null, + inPlay: 0, + hasSelection: false, count: 0, cap: 50 }, notice: null, capture: { available: false, reason: null }, prompt: null, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 1321425..6689c8e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -53,6 +53,8 @@ export interface ClipView { readonly preview: string readonly capturedAt: string readonly sourceApp: string | null + /** Whether this clip is in play. True for every clip when nothing has been chosen (PLAN.md 3). */ + readonly isSelected: boolean } export interface SpoolView { @@ -63,6 +65,10 @@ export interface SpoolView { readonly cursorClipId: string | null readonly count: number readonly cap: number + /** How many clips a serve or a whole-spool paste would act on — `count` unless a subset is chosen. */ + readonly inPlay: number + /** Whether the user has chosen a subset, as distinct from the empty selection that means all. */ + readonly hasSelection: boolean } /** The four choices offered by the consent prompt (PLAN.md 4). */ @@ -228,6 +234,8 @@ export const CHANNELS = { toggleMode: 'spool:toggle-mode', setHotkey: 'spool:set-hotkey', setAutoPaste: 'spool:set-auto-paste', + toggleClipSelected: 'spool:toggle-clip-selected', + selectAllClips: 'spool:select-all-clips', resetHotkey: 'spool:reset-hotkey', resumeCapture: 'spool:resume-capture', deleteSpools: 'spool:delete-spools',