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
20 changes: 20 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions src/main/core/join.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { selectedClips } from './selection'
import type { Spool } from './types'

/**
Expand Down Expand Up @@ -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<string> = 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 {
Expand Down
62 changes: 62 additions & 0 deletions src/main/core/selection.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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)
})
})
51 changes: 51 additions & 0 deletions src/main/core/selection.ts
Original file line number Diff line number Diff line change
@@ -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<string>): 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<string>
): 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<string>): 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<string>): Set<string> {
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<string>, clipId: string): Set<string> {
const next = new Set(selection)
if (next.has(clipId)) next.delete(clipId)
else next.add(clipId)
return next
}
34 changes: 29 additions & 5 deletions src/main/core/spool.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -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<string> = 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 } }
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 14 additions & 3 deletions src/main/ipc/view.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,18 +9,28 @@ 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<string> = new Set()
): SpoolView {
return {
name: spool.name,
mode: spool.mode,
clips: spool.clips.map((clip) => ({
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
}
}
109 changes: 109 additions & 0 deletions src/main/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading