From 09695a530bf69426e258db04110a06a2cf02ec2e Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Fri, 11 Sep 2026 07:21:03 -0500 Subject: [PATCH] Choose which clips are in play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spool is not always wanted whole. Ticking clips narrows what the next serve delivers and what the whole-spool paste joins, using one working set for both, so Win+Alt+U, Win+Alt+V and the button cannot come to 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. Clearing the selection and selecting everything are therefore the same act, and unticking the last box returns to all rather than to none. 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 or the spool is cleared, and drops any deleted clip, because a set still naming a clip that is gone makes the button promise more than it can deliver. Serving steps over what is not in play and wraps among the chosen clips. A cursor left 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 in play instead of refusing. The button names what it will take: "Put all 15 on the clipboard" becomes "Put 3 of 15", keeping the total visible so a selection reads as a narrowing rather than as the whole truth. Co-Authored-By: Claude Opus 5 --- PLAN.md | 20 ++++ src/main/core/join.ts | 13 ++- src/main/core/selection.test.ts | 62 ++++++++++ src/main/core/selection.ts | 51 ++++++++ src/main/core/spool.ts | 34 +++++- src/main/ipc/index.ts | 6 + src/main/ipc/view.ts | 17 ++- src/main/session.test.ts | 109 ++++++++++++++++++ src/main/session.ts | 41 ++++++- src/preload/index.ts | 7 ++ src/renderer/components/ClipList.tsx | 14 ++- src/renderer/components/ExpandedView.tsx | 17 ++- src/renderer/env.d.ts | 2 + .../helpers/ArrangeListHelper.test.ts | 4 +- src/renderer/helpers/ClipListHelper.test.ts | 5 +- src/renderer/helpers/ExpandedViewHelper.ts | 15 ++- src/renderer/state/useAppState.ts | 4 +- src/shared/ipc.ts | 8 ++ 18 files changed, 406 insertions(+), 23 deletions(-) create mode 100644 src/main/core/selection.test.ts create mode 100644 src/main/core/selection.ts 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',