From c947716091fdc91bdcbfcc04292770688c39766d Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Sun, 6 Sep 2026 08:32:34 -0500 Subject: [PATCH] Remove starred spools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built at M11, shipped, and never reached for. It worked and was tested, but a feature nobody uses is not free: it was a star button beside every spool, a rule in every capacity decision, a column in the schema, and a clause in every explanation of what Clear spools does. The cost of a feature is paid by everyone who reads past it. The capacity floor gets simpler rather than weaker. Its solvability used to rest on the starred reserve — starring capped at half the budget, the floor at 95%, so at least 45% stayed reclaimable. With nothing exempt but the default spool, every saved spool is a candidate and the argument collapses into arithmetic. Clear spools now says what it does: it takes every saved spool and keeps the default one. The old label had to name what it spared, which is what made its disagreement with the action a bug rather than a wording problem. The is_starred column stays at v4, always zero, written by nothing and read by nothing. SQLite cannot drop a column without rebuilding the table, and rebuilding every user's spools table to reclaim one integer per row is real risk for a benefit nobody can see. A migration records what shipped, not what the code does now. PLAN.md 10 is kept as a record of the removal rather than deleted. The reasoning in it was sound; it was not why the feature went. Co-Authored-By: Claude Opus 5 --- PLAN.md | 80 ++---- src/main/core/capacity.test.ts | 23 +- src/main/core/capacity.ts | 7 +- src/main/core/limits.ts | 6 - src/main/core/spool.ts | 2 - src/main/core/starring.test.ts | 163 ------------ src/main/core/starring.ts | 119 --------- src/main/core/types.ts | 1 - src/main/ipc/index.ts | 4 - src/main/session.test.ts | 248 ++---------------- src/main/session.ts | 105 +------- src/main/store/migrations.ts | 13 +- src/main/store/repository.ts | 12 +- src/main/store/store.test.ts | 4 - src/preload/index.ts | 5 +- src/renderer/components/CapacityAdvisor.tsx | 2 +- src/renderer/components/SpoolSidebar.tsx | 12 - src/renderer/env.d.ts | 1 - .../helpers/SpoolSidebarHelper.test.ts | 23 +- src/renderer/helpers/SpoolSidebarHelper.ts | 16 +- src/shared/ipc.ts | 3 - 21 files changed, 94 insertions(+), 755 deletions(-) delete mode 100644 src/main/core/starring.test.ts delete mode 100644 src/main/core/starring.ts diff --git a/PLAN.md b/PLAN.md index 81fe071..51efa59 100644 --- a/PLAN.md +++ b/PLAN.md @@ -76,7 +76,6 @@ These hold at every commit. A change that breaks one is wrong even if it passes | **Spool** | A named, ordered list of clips with a mode and a cursor. The app's central object, and its namesake. | | **Default spool** | The implicit rolling buffer that captures when the user has made none. Mode FIFO. Cannot be deleted; can be cleared. | | **Saved spool** | A spool the user named and kept, reusable across sessions and never silently trimmed. See §3 Limits. | -| **Starred spool** | A saved spool marked to survive routine clearing. Sorts to the top. See §10. | | **Cursor** | The clip that the next serve will deliver. Stored as a clip **identity**, not an index. | | **Serve** | Write the cursor's clip to the system clipboard, then advance the cursor. | | **Mode** | `fifo` or `lifo`. Sets the direction the cursor travels. | @@ -821,66 +820,31 @@ but the gate should then still refuse capture rather than trap the window. --- -## 10. Starred spools +## 10. Starred spools — removed -A star marks a spool the user means to keep. Starred spools sort to the top of every list and -survive the routine clearing that unstarred ones do not. +**This section described a feature that was built, shipped in M11, and then taken out.** It is kept +as a record rather than deleted, because the reasoning it contains was sound and the reason it went +was not that the reasoning was wrong. -Two clearing commands, worded so they cannot be mistaken for each other: +A star marked a spool the user meant to keep: it sorted to the top of every list, survived Clear +spools, and was never proposed by any capacity state. It was capped at five spools and half the byte +budget, and the cap was enforced *before* the user could rely on it — "you can't star this" being an +honest limit where "unstar this so we can delete it" would have been a promise revoked under +pressure. That reserve was also load-bearing elsewhere: it was what proved the §9 capacity floor +always solvable, since starred usage could never exceed half the budget while the floor sat at 95%. -| Command | Effect | -|---|---| -| **Clear spools** | Deletes unstarred spools. Meant for daily use. The button states what it spares: *Clear 12 spools · 3 starred kept.* | -| **Reset everything** | The failsafe. Deletes every spool including starred, drops the keychain entry, returns to first run. Typed confirmation, and the only operation that touches a starred spool without it being unstarred first. | - -Rules: - -- The default spool cannot be starred. It is a buffer, not an artifact. -- A star is unconditional. **No capacity state ever proposes deleting a starred spool, or asks for - it to be unstarred first.** It is not a candidate at 90%, not a candidate at 95%, and untouched by - Clear spools. Only the user unstars, and only Reset everything overrides it. -- Unstarring is always available and never asks for confirmation. Starring is the commitment; - releasing it is not. - -### The reserve, and why it is not a broken promise - -Starring is capped at five, and **starred spools may hold at most half the store budget** -(§3 Limits). When a star would breach that, the star is refused — and so is further capture into an -already-starred spool that has reached it. Neither refusal ever deletes anything. - -Declining to make a promise is not the same act as breaking one. "You can't star this, because -starred spools already hold half your space" is an honest limit stated before the user relies on -it. "Unstar this so we can delete it" is a promise revoked under pressure, which is worse than never -having offered the star. - -The reserve is also what makes the §9 floor solvable without touching a star, and the arithmetic is -worth stating because it is the whole justification: +It was removed because the person it was built for did not want it. Not because it was wrong, or +badly built, or hard to maintain — it worked, it was tested, and it cost nothing to keep. It went +because a feature nobody reaches for is not free: it is a star button next to every spool, a rule in +every capacity decision, a column in the schema, and a paragraph in every explanation of what Clear +spools does. **The cost of a feature is paid by everyone who has to read past it.** -> Starred usage is capped at 50% of the budget. The floor triggers at 95%. So non-starred usage at -> the floor is at least 95% − 50% = **45% of the budget**, always available to reclaim. - -Because the reserve is a *fraction*, that proof holds at any budget — including a small one on a -constrained device, which is the case that would otherwise break it. - -Two notes on how it behaves in practice. The ceiling is measured against real bytes, not worst-case -ones, so a user with five ordinary starred spools will never encounter it; it engages only for -someone keeping megabyte-scale pastes. And if a database file ever arrives with starred content -already past the reserve — an older build, a changed cap — the floor still resolves without breaking -the promise, because **Pause capture** and **Reset everything** are both always available. That is -what makes Pause capture load-bearing rather than a courtesy. - -### On extending this - -Raising the starred allowance or the store budget is a coherent paid add-on. Both are local limits, -neither needs a network, and §5 survives intact. - -**Cloud storage is not.** Selling synced storage means shipping an HTTP client, which deletes -invariant 1, the CI gate that proves it, and the one sentence in the privacy panel that is the reason -to install this instead of any of the dozen clipboard managers that already exist. It would be a -different product wearing this one's name. If it is ever built, it belongs in a separate application -making its own promises. - ---- +Two consequences worth naming. The §9 floor no longer needs the solvability argument at all: with +nothing exempt but the default spool, every saved spool is reclaimable and the proof collapses into +arithmetic. And the `is_starred` column stays in the schema at v4, always zero, written by nothing +and read by nothing — SQLite cannot drop a column without rebuilding the table, and rebuilding every +user's `spools` table to reclaim one integer per row is real risk for a benefit no one can see. A +migration is a record of what shipped, not a description of the code as it stands today. ## 11. Milestones @@ -1101,7 +1065,7 @@ the same figures on demand. The v1 → v2 migration is tested against a database --- -### M11 — Starred spools +### M11 — Starred spools *(shipped, then removed; see §10)* **In scope** — `is_starred` with a forward migration — `schema_version = 4` in the end, after M9's retention column and M10's `last_used_at` (§7); star and unstar; diff --git a/src/main/core/capacity.test.ts b/src/main/core/capacity.test.ts index b95bbfb..5dba536 100644 --- a/src/main/core/capacity.test.ts +++ b/src/main/core/capacity.test.ts @@ -185,23 +185,16 @@ describe('the floor at 95% (PLAN.md 9)', () => { expect(ranked.map((c) => c.id)).toEqual(['vast', 'tiny']) }) - it('is solvable with five starred spools sitting exactly at the reserve', () => { - // The arithmetic of PLAN.md 10: starred is capped at half the budget, the floor is at 95%, so - // at least 45% of the budget is non-starred and therefore reclaimable — always. - const reserve = STORE_BYTE_BUDGET / 2 - const atFloor = STORE_BYTE_BUDGET * GATE_AT - const nonStarred = atFloor - reserve - - // Five starred spools holding the whole reserve, and the rest in ordinary ones. - const deletable = Array.from({ length: 9 }, (_, i) => - candidate({ id: `plain-${i}`, bytes: nonStarred / 9 }) + it('is solvable at the floor, because every saved spool is a candidate', () => { + // This once needed the starred reserve to hold: starring was capped at half the budget so that + // at least 45% stayed reclaimable. With starring gone nothing is exempt but the default spool, + // and the argument collapses into arithmetic. + const atFloor = Math.ceil(STORE_BYTE_BUDGET * 0.95) + const spools = Array.from({ length: 10 }, (_, i) => + candidate({ id: `plain-${i}`, bytes: Math.ceil(atFloor / 10) }) ) - const ranked = rankCandidates(deletable, 'largest') - const reclaimable = ranked.reduce((total, c) => total + c.bytes, 0) - // Rounded to whole bytes: writing the percentages as decimals leaves the two sides a - // fraction of a byte apart, which says nothing about the reserve. - expect(Math.round(reclaimable)).toBeGreaterThanOrEqual(Math.round(STORE_BYTE_BUDGET * 0.45)) + const reclaimable = spools.reduce((total, spool) => total + spool.bytes, 0) expect(reclaimable).toBeGreaterThanOrEqual(bytesOverFloor(atFloor, STORE_BYTE_BUDGET)) }) }) diff --git a/src/main/core/capacity.ts b/src/main/core/capacity.ts index 9f095ff..268a9ea 100644 --- a/src/main/core/capacity.ts +++ b/src/main/core/capacity.ts @@ -148,9 +148,10 @@ export function shouldGate(measure: Measure): boolean { /** * How much has to go to get back under the floor. * - * The reserve of PLAN.md 10 is what makes this always solvable without touching a star: starred - * usage is capped at half the budget and the floor is at 95%, so at least 45% of the budget is - * non-starred and reclaimable. + * Always solvable, because every saved spool is a candidate. This used to need an argument — the + * starred reserve was capped at half the budget so that at least 45% stayed reclaimable — and it + * stopped needing one when starring was removed. Nothing is exempt now except the default spool, + * which is a buffer rather than something the user built. */ export function bytesOverFloor(storeBytes: number, cap: number): number { return Math.max(Math.ceil(storeBytes - cap * GATE_AT), 0) diff --git a/src/main/core/limits.ts b/src/main/core/limits.ts index 57c8208..e5517c7 100644 --- a/src/main/core/limits.ts +++ b/src/main/core/limits.ts @@ -18,11 +18,5 @@ export const CLIP_BYTE_CAP = 1024 * 1024 /** The whole store's budget. The capacity advisor of PLAN.md 9 steps in well before it. */ export const STORE_BYTE_BUDGET = 512 * 1024 * 1024 -/** Star another by unstarring one first. Arrives at M11. */ -export const STARRED_SPOOL_CAP = 5 - -/** The share of the budget starred spools may hold, which keeps the capacity floor solvable. */ -export const STARRED_BUDGET_SHARE = 0.5 - /** How many characters of a clip the preview keeps (PLAN.md 7). */ export const PREVIEW_LENGTH = 120 diff --git a/src/main/core/spool.ts b/src/main/core/spool.ts index 015ae61..4cbbd6d 100644 --- a/src/main/core/spool.ts +++ b/src/main/core/spool.ts @@ -20,7 +20,6 @@ export function createSpool(input: { clips?: readonly Clip[] retentionHours?: number | null lastUsedAt?: string | null - isStarred?: boolean }): Spool { const clips = input.clips ?? [] const mode = input.mode ?? 'fifo' @@ -33,7 +32,6 @@ export function createSpool(input: { cursorClipId: clips.length === 0 ? null : startingClip(clips, mode).id, retentionHours: input.retentionHours ?? null, lastUsedAt: input.lastUsedAt ?? null, - isStarred: input.isStarred ?? false } } diff --git a/src/main/core/starring.test.ts b/src/main/core/starring.test.ts deleted file mode 100644 index 34dfd94..0000000 --- a/src/main/core/starring.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { STARRED_SPOOL_CAP, STORE_BYTE_BUDGET } from './limits' -import { - STARRED_BYTE_RESERVE, - canStar, - clearableSpools, - starredBytes, - starredFirst, - starredReserveReached, - type StarrableSpool -} from './starring' - -const MIB = 1024 * 1024 - -const spool = (over: Partial & { id: string }): StarrableSpool => ({ - isDefault: false, - isStarred: false, - bytes: MIB, - ...over -}) - -describe('what may be starred (PLAN.md 10)', () => { - it('stars an ordinary saved spool', () => { - expect(canStar([spool({ id: 'a' })], 'a')).toEqual({ ok: true }) - }) - - it('refuses the default spool, which is a buffer rather than an artifact', () => { - const decision = canStar([spool({ id: 'default', isDefault: true })], 'default') - - expect(decision.ok).toBe(false) - expect(decision.ok === false && decision.reason).toBe('default_spool') - expect(decision.ok === false && decision.message).toMatch(/buffer/i) - }) - - it('refuses the sixth star, naming the limit that stopped it', () => { - const spools = [ - ...Array.from({ length: STARRED_SPOOL_CAP }, (_, i) => - spool({ id: `starred-${i}`, isStarred: true }) - ), - spool({ id: 'hopeful' }) - ] - - const decision = canStar(spools, 'hopeful') - - expect(decision.ok).toBe(false) - expect(decision.ok === false && decision.reason).toBe('star_cap') - expect(decision.ok === false && decision.message).toContain(String(STARRED_SPOOL_CAP)) - // And it says plainly that refusing costs nothing. - expect(decision.ok === false && decision.message).toMatch(/nothing is deleted/i) - }) - - it('refuses a star that would push starred bytes past the reserve', () => { - const spools = [ - spool({ id: 'big', isStarred: true, bytes: STARRED_BYTE_RESERVE - 10 * MIB }), - spool({ id: 'hopeful', bytes: 40 * MIB }) - ] - - const decision = canStar(spools, 'hopeful') - - expect(decision.ok).toBe(false) - expect(decision.ok === false && decision.reason).toBe('reserve') - expect(decision.ok === false && decision.message).toMatch(/half the space/i) - expect(decision.ok === false && decision.message).toMatch(/nothing has been deleted/i) - }) - - it('allows a star that lands exactly on the reserve', () => { - const spools = [ - spool({ id: 'big', isStarred: true, bytes: STARRED_BYTE_RESERVE - 5 * MIB }), - spool({ id: 'hopeful', bytes: 5 * MIB }) - ] - - expect(canStar(spools, 'hopeful')).toEqual({ ok: true }) - }) - - it('does not object to a spool that is already starred', () => { - const spools = Array.from({ length: STARRED_SPOOL_CAP }, (_, i) => - spool({ id: `starred-${i}`, isStarred: true }) - ) - - expect(canStar(spools, 'starred-0')).toEqual({ ok: true }) - }) - - it('measures the reserve against real bytes, so ordinary spools never meet it', () => { - const ordinary = Array.from({ length: STARRED_SPOOL_CAP - 1 }, (_, i) => - spool({ id: `s${i}`, isStarred: true, bytes: 2 * MIB }) - ) - - expect(canStar([...ordinary, spool({ id: 'one more', bytes: 2 * MIB })], 'one more')).toEqual({ - ok: true - }) - }) -}) - -describe('the reserve arithmetic (PLAN.md 10)', () => { - it('is half the store budget', () => { - expect(STARRED_BYTE_RESERVE).toBe(STORE_BYTE_BUDGET / 2) - }) - - it('leaves at least 45% of the budget reclaimable at the 95% floor', () => { - // The whole justification: starred ≤ 50%, floor at 95%, so 45% is always non-starred. - // Compared to the byte: the two sides differ by a fraction of one in floating point, which is - // an artifact of writing the percentages as decimals rather than anything about the reserve. - const reclaimableAtFloor = STORE_BYTE_BUDGET * 0.95 - STARRED_BYTE_RESERVE - - expect(Math.round(reclaimableAtFloor)).toBeGreaterThanOrEqual( - Math.round(STORE_BYTE_BUDGET * 0.45) - ) - }) - - it('knows when a starred spool must stop accepting clips', () => { - const under = [spool({ id: 'a', isStarred: true, bytes: STARRED_BYTE_RESERVE - 1 })] - const at = [spool({ id: 'a', isStarred: true, bytes: STARRED_BYTE_RESERVE })] - - expect(starredReserveReached(under)).toBe(false) - expect(starredReserveReached(at)).toBe(true) - }) - - it('counts only starred spools towards it', () => { - const spools = [ - spool({ id: 'starred', isStarred: true, bytes: 10 * MIB }), - spool({ id: 'plain', bytes: 400 * MIB }) - ] - - expect(starredBytes(spools)).toBe(10 * MIB) - }) -}) - -describe('ordering and clearing (PLAN.md 10)', () => { - const listed = [ - { id: 'default', isDefault: true, isStarred: false }, - { id: 'plain-a', isDefault: false, isStarred: false }, - { id: 'starred-a', isDefault: false, isStarred: true }, - { id: 'plain-b', isDefault: false, isStarred: false }, - { id: 'starred-b', isDefault: false, isStarred: true } - ] - - it('sorts starred spools to the top, with the default spool above them', () => { - expect(starredFirst(listed).map((s) => s.id)).toEqual([ - 'default', - 'starred-a', - 'starred-b', - 'plain-a', - 'plain-b' - ]) - }) - - it('keeps the order stable among equals, so lists do not shuffle', () => { - const twice = starredFirst(starredFirst(listed)) - - expect(twice.map((s) => s.id)).toEqual(starredFirst(listed).map((s) => s.id)) - }) - - it('clears unstarred spools and spares the starred ones', () => { - const { clearing, sparing } = clearableSpools(listed) - - expect(clearing.map((s) => s.id)).toEqual(['plain-a', 'plain-b']) - expect(sparing.map((s) => s.id)).toEqual(['starred-a', 'starred-b']) - }) - - it('never clears the default spool, which has to exist to catch a copy', () => { - expect(clearableSpools(listed).clearing.some((s) => s.isDefault)).toBe(false) - }) -}) diff --git a/src/main/core/starring.ts b/src/main/core/starring.ts deleted file mode 100644 index cea92bf..0000000 --- a/src/main/core/starring.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { STARRED_BUDGET_SHARE, STARRED_SPOOL_CAP, STORE_BYTE_BUDGET } from './limits' - -/** - * Starred spools (PLAN.md 10). - * - * A star marks a spool the user means to keep. It sorts to the top of every list and survives the - * routine clearing that unstarred spools do not. - * - * **A star is unconditional.** No capacity state ever proposes deleting a starred spool or asks for - * it to be unstarred first — not at 90%, not at the floor, and not by Clear spools. Only the user - * unstars, and only Reset everything overrides it. - * - * Which is exactly why starring is capped, and why the cap is refused *before* the user relies on - * it: declining to make a promise is not the same act as breaking one. "You can't star this, - * because starred spools already hold half your space" is an honest limit. "Unstar this so we can - * delete it" is a promise revoked under pressure, and worse than never having offered the star. - */ - -/** The most starred bytes allowed — half the budget, as a fraction so the proof survives a small one. */ -export const STARRED_BYTE_RESERVE = STORE_BYTE_BUDGET * STARRED_BUDGET_SHARE - -/** Enough of a spool to decide whether it may be starred. */ -export interface StarrableSpool { - readonly id: string - readonly isDefault: boolean - readonly isStarred: boolean - readonly bytes: number -} - -export type StarRefusal = - /** The default spool is a buffer, not an artifact (PLAN.md 10). */ - | { readonly reason: 'default_spool'; readonly message: string } - | { readonly reason: 'star_cap'; readonly message: string } - | { readonly reason: 'reserve'; readonly message: string } - -export type StarDecision = { readonly ok: true } | { readonly ok: false } & StarRefusal - -/** - * Whether this spool may be starred, and if not, which limit stopped it. - * - * Every refusal names the limit — a refusal the user cannot act on is just a wall — and **no - * refusal ever deletes anything**. - */ -export function canStar(spools: readonly StarrableSpool[], spoolId: string): StarDecision { - const target = spools.find((spool) => spool.id === spoolId) - if (target === undefined) return { ok: false, reason: 'star_cap', message: 'That spool is gone.' } - - if (target.isDefault) { - return { - ok: false, - reason: 'default_spool', - message: 'The default spool cannot be starred: it is a buffer, not something you built.' - } - } - - if (target.isStarred) return { ok: true } - - const starred = spools.filter((spool) => spool.isStarred) - if (starred.length >= STARRED_SPOOL_CAP) { - return { - ok: false, - reason: 'star_cap', - message: `${STARRED_SPOOL_CAP} spools are already starred. Unstar one first — nothing is deleted either way.` - } - } - - const starredBytes = starred.reduce((total, spool) => total + spool.bytes, 0) - if (starredBytes + target.bytes > STARRED_BYTE_RESERVE) { - return { - ok: false, - reason: 'reserve', - message: - `Starred spools may hold at most half the space Spool keeps for clips, and this would go ` + - `past it. Nothing has been deleted — unstar something, or leave this one unstarred.` - } - } - - return { ok: true } -} - -/** Bytes currently held by starred spools, which the reserve is measured against. */ -export function starredBytes(spools: readonly StarrableSpool[]): number { - return spools - .filter((spool) => spool.isStarred) - .reduce((total, spool) => total + spool.bytes, 0) -} - -/** - * Whether a starred spool has reached the reserve and must refuse further capture. - * - * Refusing capture is the same shape as a saved spool refusing at its clip cap: it stops, it says - * so, and it deletes nothing (PLAN.md 3, Limits). - */ -export function starredReserveReached(spools: readonly StarrableSpool[]): boolean { - return starredBytes(spools) >= STARRED_BYTE_RESERVE -} - -/** Starred first, then the rest — the order every list uses (PLAN.md 10). */ -export function starredFirst( - spools: readonly T[] -): T[] { - return [...spools].sort((a, b) => { - // The default spool stays at the top: it is where copies land, and moving it would be a - // surprise every time something is starred. - if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1 - if (a.isStarred !== b.isStarred) return a.isStarred ? -1 : 1 - return 0 - }) -} - -/** What Clear spools would remove, and what it would spare (PLAN.md 10). */ -export function clearableSpools( - spools: readonly T[] -): { readonly clearing: T[]; readonly sparing: T[] } { - return { - clearing: spools.filter((spool) => !spool.isStarred && !spool.isDefault), - sparing: spools.filter((spool) => spool.isStarred) - } -} diff --git a/src/main/core/types.ts b/src/main/core/types.ts index ace2e47..6d81a59 100644 --- a/src/main/core/types.ts +++ b/src/main/core/types.ts @@ -49,7 +49,6 @@ export interface Spool { * Marked to survive routine clearing, and never proposed by any capacity state (PLAN.md 10). * Only the user unstars, and only Reset everything overrides it. */ - readonly isStarred: boolean } /** Why a capture did not happen. Every one of these is something the user is told (PLAN.md 3). */ diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index ff61947..1d9f411 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -94,9 +94,6 @@ export function registerIpc( actions.resetHotkey(action) ) ipcMain.handle(CHANNELS.resumeCapture, () => session.resumeCapture()) - ipcMain.handle(CHANNELS.setStarred, (_event, spoolId: string, starred: boolean) => - session.setStarred(spoolId, starred) - ) ipcMain.handle(CHANNELS.clearSpools, () => session.clearSpools()) ipcMain.handle(CHANNELS.deleteSpools, (_event, spoolIds: string[]) => session.deleteSpools(spoolIds) @@ -137,7 +134,6 @@ export function registerIpc( ipcMain.removeHandler(CHANNELS.setHotkey) ipcMain.removeHandler(CHANNELS.resetHotkey) ipcMain.removeHandler(CHANNELS.resumeCapture) - ipcMain.removeHandler(CHANNELS.setStarred) ipcMain.removeHandler(CHANNELS.clearSpools) ipcMain.removeHandler(CHANNELS.deleteSpools) ipcMain.removeHandler(CHANNELS.clearSpool) diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 3e027cc..cbcf7de 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -591,8 +591,7 @@ describe('persistence (PLAN.md 11, M6)', () => { ], cursorClipId: 'b', retentionHours: null, - lastUsedAt: null, - isStarred: false + lastUsedAt: null } const { session } = started() @@ -986,8 +985,7 @@ describe('the capacity advisor (PLAN.md 9)', () => { ], cursorClipId: `${id}-clip`, retentionHours: null, - lastUsedAt, - isStarred: false + lastUsedAt }) const defaultSpool: Spool = { @@ -998,8 +996,7 @@ describe('the capacity advisor (PLAN.md 9)', () => { clips: [], cursorClipId: null, retentionHours: null, - lastUsedAt: null, - isStarred: false + lastUsedAt: null } /** A store seeded past ninety per cent of the byte budget. */ @@ -1130,11 +1127,9 @@ describe('the capacity advisor (PLAN.md 9)', () => { }) }) -describe('starred spools (PLAN.md 10)', () => { +describe('clearing spools (PLAN.md 9)', () => { const MIB = 1024 * 1024 const names = (session: Session) => session.getState().spools.map((s) => s.name) - const starred = (session: Session) => - session.getState().spools.filter((s) => s.isStarred).map((s) => s.name) /** A saved spool of a given size, as the store would report it. */ const sized = (id: string, name: string, bytes: number): Spool => ({ @@ -1155,8 +1150,7 @@ describe('starred spools (PLAN.md 10)', () => { ], cursorClipId: `${id}-clip`, retentionHours: null, - lastUsedAt: '2026-08-01T00:00:00.000Z', - isStarred: false + lastUsedAt: '2026-08-01T00:00:00.000Z' }) const withSpools = (spools: Spool[], bytes = 0) => { @@ -1174,93 +1168,9 @@ describe('starred spools (PLAN.md 10)', () => { clips: [], cursorClipId: null, retentionHours: null, - lastUsedAt: null, - isStarred: false + lastUsedAt: null } - it('moves a starred spool to the top of the list', () => { - const { session } = withSpools([ - defaultSpool, - sized('a', 'Alpha', MIB), - sized('b', 'Beta', MIB), - sized('c', 'Gamma', MIB) - ]) - - session.setStarred('c', true) - - // The default spool stays first; the star sorts above the unstarred rest. - expect(names(session)).toEqual(['Default spool', 'Gamma', 'Alpha', 'Beta']) - }) - - it('refuses the sixth star, naming the limit and deleting nothing', () => { - const spools = [ - defaultSpool, - ...Array.from({ length: 5 }, (_, i) => ({ ...sized(`s${i}`, `Starred ${i}`, MIB), isStarred: true })), - sized('hopeful', 'Hopeful', MIB) - ] - const { session } = withSpools(spools) - - session.setStarred('hopeful', true) - - expect(starred(session)).toHaveLength(5) - expect(session.getState().notice?.message).toMatch(/5 spools are already starred/i) - expect(names(session)).toContain('Hopeful') - }) - - it('refuses a star that would push starred bytes past the reserve', () => { - const reserve = 256 * MIB - const { session } = withSpools([ - defaultSpool, - { ...sized('big', 'Big', reserve - 10 * MIB), isStarred: true }, - sized('hopeful', 'Hopeful', 40 * MIB) - ]) - - session.setStarred('hopeful', true) - - expect(starred(session)).toEqual(['Big']) - expect(session.getState().notice?.message).toMatch(/half the space/i) - expect(session.getState().notice?.message).toMatch(/nothing has been deleted/i) - }) - - it('refuses to star the default spool', () => { - const { session } = withSpools([defaultSpool, sized('a', 'Alpha', MIB)]) - - session.setStarred('default', true) - - expect(starred(session)).toEqual([]) - expect(session.getState().notice?.message).toMatch(/buffer/i) - }) - - it('unstars without asking anything', () => { - const { session } = withSpools([ - defaultSpool, - { ...sized('a', 'Alpha', MIB), isStarred: true } - ]) - - session.setStarred('a', false) - - expect(starred(session)).toEqual([]) - // No prompt, no confirmation, no notice to dismiss. - expect(session.getState().prompt).toBeNull() - }) - - it('Clear spools deletes the unstarred and spares the starred', () => { - const { session, saved } = withSpools([ - defaultSpool, - { ...sized('keep', 'Keep me', MIB), isStarred: true }, - sized('go1', 'Go one', MIB), - sized('go2', 'Go two', MIB) - ]) - - session.clearSpools() - - expect(names(session)).toEqual(['Default spool', 'Keep me']) - expect(saved.deletedBatches).toEqual([['go1', 'go2']]) - }) - - // The bug this exists for: the button counted every unstarred spool, including the active one, - // while the action skipped the active one. With a single unstarred spool that happened to be - // active, "Clear 1 spool" did nothing at all. it('clears the active spool too, because the button counts it', () => { const { session, saved } = withSpools([defaultSpool, sized('only', 'Only one', MIB)]) session.setActiveSpool('only') @@ -1283,70 +1193,17 @@ describe('starred spools (PLAN.md 10)', () => { expect(names(session)).toEqual(['Default spool']) }) - it('never offers a starred spool to the capacity advisor', () => { - const { session } = withSpools( - [ - defaultSpool, - { ...sized('starred', 'Starred', 200 * MIB), isStarred: true }, - sized('plain', 'Plain', 200 * MIB) - ], - 470 * MIB - ) - - const { candidates } = session.getState().capacity - expect(candidates.map((c) => c.name)).toEqual(['Plain']) - }) - - it('will not mass delete a starred spool even when handed its id', () => { + it('Clear spools deletes every saved spool and keeps the default', () => { const { session, saved } = withSpools([ defaultSpool, - { ...sized('starred', 'Starred', MIB), isStarred: true }, - sized('plain', 'Plain', MIB) + sized('go1', 'Go one', MIB), + sized('go2', 'Go two', MIB) ]) - session.deleteSpools(['starred', 'plain']) - - expect(names(session)).toContain('Starred') - expect(saved.deletedBatches).toEqual([['plain']]) - }) - - it('refuses capture into a starred spool that has reached the reserve, and says why', () => { - const reserve = 256 * MIB - const { session, watcher } = (() => { - const s = started() - const fake = fakeStore({ - spools: [defaultSpool, { ...sized('full', 'Full', reserve), isStarred: true }], - bytes: reserve - }) - s.session.attachStore(fake.store) - return s - })() - - session.setActiveSpool('full') - const before = session.getState().spool.count - - watcher.change(text('a clip that will not fit the promise')) - - expect(session.getState().spool.count).toBe(before) - expect(session.getState().notice?.message).toMatch(/starred spools may hold/i) - expect(session.getState().notice?.message).toMatch(/nothing was deleted/i) - }) - - it('captures normally into a starred spool below the reserve', () => { - const { session, watcher } = (() => { - const s = started() - const fake = fakeStore({ - spools: [defaultSpool, { ...sized('small', 'Small', MIB), isStarred: true }], - bytes: MIB - }) - s.session.attachStore(fake.store) - return s - })() - - session.setActiveSpool('small') - watcher.change(text('an ordinary clip')) + session.clearSpools() - expect(session.getState().spool.clips.map((c) => c.preview)).toContain('an ordinary clip') + expect(names(session)).toEqual(['Default spool']) + expect(saved.deletedBatches).toEqual([['go1', 'go2']]) }) }) @@ -1354,7 +1211,7 @@ describe('the capacity floor (PLAN.md 9)', () => { const MIB = 1024 * 1024 const BUDGET = 512 * MIB - const sized = (id: string, name: string, bytes: number, starred = false): Spool => ({ + const sized = (id: string, name: string, bytes: number): Spool => ({ id, name, kind: 'saved', @@ -1372,8 +1229,7 @@ describe('the capacity floor (PLAN.md 9)', () => { ], cursorClipId: `${id}-clip`, retentionHours: null, - lastUsedAt: '2026-08-01T00:00:00.000Z', - isStarred: starred + lastUsedAt: '2026-08-01T00:00:00.000Z' }) const defaultSpool: Spool = { @@ -1384,8 +1240,7 @@ describe('the capacity floor (PLAN.md 9)', () => { clips: [], cursorClipId: null, retentionHours: null, - lastUsedAt: null, - isStarred: false + lastUsedAt: null } /** A store past the floor: 97% of the budget across three ordinary spools. */ @@ -1519,81 +1374,8 @@ describe('the capacity floor (PLAN.md 9)', () => { expect(session.getState().spool.count).toBe(1) }) - it('never names a starred spool at the gate, and never asks for an unstar', () => { - const each = Math.round((BUDGET * 0.97) / 3) - const { session } = started() - session.attachStore( - fakeStore({ - spools: [ - defaultSpool, - sized('starred', 'Starred and safe', each, true), - sized('plain-a', 'Plain A', each), - sized('plain-b', 'Plain B', each) - ], - bytes: Math.round(BUDGET * 0.97) - }).store - ) - - const { capacity } = session.getState() - expect(capacity.gated).toBe(true) - expect(capacity.candidates.map((c) => c.name)).toEqual(['Plain A', 'Plain B']) - expect(JSON.stringify(capacity)).not.toContain('Starred and safe') - expect(JSON.stringify(capacity).toLowerCase()).not.toContain('unstar') - }) - - it('stays solvable with five starred spools sitting exactly at the reserve', () => { - // PLAN.md 10's arithmetic, as the app actually assembles it: starred at half the budget, the - // rest ordinary, and the gate must still offer enough to get back under the floor. - const reserve = BUDGET / 2 - const starredEach = reserve / 5 - const nonStarred = Math.round(BUDGET * 0.97) - reserve - const { session } = started() - session.attachStore( - fakeStore({ - spools: [ - defaultSpool, - ...Array.from({ length: 5 }, (_, i) => - sized(`star-${i}`, `Starred ${i}`, starredEach, true) - ), - ...Array.from({ length: 4 }, (_, i) => sized(`plain-${i}`, `Plain ${i}`, nonStarred / 4)) - ], - bytes: Math.round(BUDGET * 0.97) - }).store - ) - const { capacity } = session.getState() - const offered = capacity.candidates.reduce((total, c) => total + c.bytes, 0) - - expect(capacity.gated).toBe(true) - expect(capacity.candidates).toHaveLength(4) - expect(offered).toBeGreaterThanOrEqual(capacity.overFloorBytes) - }) - - it('offers a door that deletes nothing even when starred content already exceeds the reserve', () => { - // The pathological case PLAN.md 10 names: an older build, or a changed cap. There may be - // nothing to offer, and the gate must still not name a starred spool. - const { session } = started() - session.attachStore( - fakeStore({ - spools: [ - defaultSpool, - sized('over-1', 'Over one', Math.round(BUDGET * 0.5), true), - sized('over-2', 'Over two', Math.round(BUDGET * 0.47), true) - ], - bytes: Math.round(BUDGET * 0.97) - }).store - ) - - const { capacity } = session.getState() - expect(capacity.gated).toBe(true) - expect(capacity.candidates).toEqual([]) - - // Pause capture still works, and still deletes nothing. - session.pauseCapture() - expect(session.getState().capacity.paused).toBe(true) - expect(session.getState().spools).toHaveLength(3) - }) }) describe('the first run (PLAN.md 11, M13)', () => { diff --git a/src/main/session.ts b/src/main/session.ts index 8ff413e..db93c9b 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -35,13 +35,6 @@ import { type MeasureName } from './core/capacity' import { expireClips, isRetentionHours } from './core/retention' -import { - canStar, - clearableSpools, - starredFirst, - starredReserveReached, - type StarrableSpool -} from './core/starring' import { arrange, clear, createSpool, deleteClip, serve, setMode } from './core/spool' import type { Clip, Mode, Spool } from './core/types' import type { ClipboardSnapshot } from './detect/admit' @@ -292,18 +285,6 @@ export class Session { return } - // A starred spool that has reached the reserve stops accepting clips (PLAN.md 10). It refuses - // and says so, exactly as a saved spool does at its clip cap, and deletes nothing. - if (this.state.spool.isStarred && starredReserveReached(this.starrable())) { - this.notice = { - category: 'unsupported', - message: - 'This starred spool has reached the half of your space that starred spools may hold. ' + - 'Nothing was deleted — unstar it, or capture into another spool.' - } - this.publish() - return - } const outcome = captureSnapshot(this.state, snapshot, this.deps) @@ -474,8 +455,7 @@ export class Session { clips: copied, cursorClipId: copied[0]?.id ?? null, retentionHours: null, - lastUsedAt: new Date().toISOString(), - isStarred: false + lastUsedAt: new Date().toISOString() } this.otherSpools = [...this.otherSpools, created] @@ -655,9 +635,6 @@ export class Session { return ( spool !== undefined && spool.kind !== 'default' && - // Starred spools are not deletable this way. Only the user unstars, and only Reset - // everything overrides it (PLAN.md 10). - !spool.isStarred && spool.id !== this.state.spool.id ) }) @@ -704,51 +681,21 @@ export class Session { this.publish() } - /** - * Star or unstar a spool (PLAN.md 10). - * - * Starring is the commitment and can be refused — by the five-star cap, by the reserve, or - * because it is the default spool — always with a message naming the limit and never deleting - * anything. **Unstarring is always available and never asks for confirmation**: releasing a - * promise is not the same act as making one. - */ - setStarred(spoolId: string, starred: boolean): void { - const target = this.allSpools().find((spool) => spool.id === spoolId) - if (target === undefined || target.isStarred === starred) return - - if (starred) { - const decision = canStar(this.starrable(), spoolId) - if (!decision.ok) { - this.notice = { category: 'unsupported', message: decision.message } - this.publish() - return - } - } - - this.replaceSpool(spoolId, (spool) => ({ ...spool, isStarred: starred })) - this.publish() - } /** - * Clear spools: deletes unstarred spools and spares the starred ones (PLAN.md 10). + * Clear spools: deletes every saved spool, keeping the default one (PLAN.md 9). * - * The everyday command, as distinct from Reset everything — which is the only operation that - * touches a starred spool without it being unstarred first. + * The everyday command, as distinct from Reset everything, which also takes the clips out of the + * default spool and the standing answers with them. */ clearSpools(): void { - const { clearing } = clearableSpools( - this.allSpools().map((spool) => ({ - id: spool.id, - isStarred: spool.isStarred, - isDefault: spool.kind === 'default' - })) - ) - - const removable = clearing.map((spool) => spool.id) + const removable = this.allSpools() + .filter((spool) => spool.kind !== 'default') + .map((spool) => spool.id) if (removable.length === 0) return // The active spool is cleared like any other. It used to be skipped, while the button went on - // counting it — so a user whose only unstarred spool was the active one pressed "Clear 1 spool" + // counting it — so a user whose only saved spool was the active one pressed "Clear 1 spool" // and watched nothing happen. The button states what it spares (PLAN.md 9); sparing something // it does not name is the one thing it must not do. if (removable.includes(this.state.spool.id)) { @@ -823,9 +770,7 @@ export class Session { bytes: size?.bytes ?? spool.clips.reduce((total, clip) => total + clip.byteLength, 0), lastUsedAt: spool.lastUsedAt, - // A starred spool is never a candidate, at any threshold: the app does not ask for a - // promise back under pressure (PLAN.md 10). - isDefault: spool.kind === 'default' || spool.isStarred, + isDefault: spool.kind === 'default', isActive: spool.id === this.state.spool.id } }) @@ -836,35 +781,6 @@ export class Session { return { ...spool, lastUsedAt: new Date().toISOString() } } - /** Every spool with what it holds, which is what the star rules are measured against. */ - private starrable(): StarrableSpool[] { - const sizeOf = new Map(this.sizes.map((size) => [size.spoolId, size.bytes])) - - return this.allSpools().map((spool) => ({ - id: spool.id, - isDefault: spool.kind === 'default', - isStarred: spool.isStarred, - bytes: - sizeOf.get(spool.id) ?? - spool.clips.reduce((total, clip) => total + clip.byteLength, 0) - })) - } - - /** Edit one spool, whether it is the active one or not, and write it through. */ - private replaceSpool(spoolId: string, change: (spool: Spool) => Spool): void { - if (spoolId === this.state.spool.id) { - this.state = { ...this.state, spool: change(this.state.spool) } - this.store?.saveSpool(this.state.spool) - this.savedSpool = this.state.spool - return - } - - this.otherSpools = this.otherSpools.map((spool) => - spool.id === spoolId ? change(spool) : spool - ) - const changed = this.otherSpools.find((spool) => spool.id === spoolId) - if (changed !== undefined) this.store?.saveSpool(changed) - } private allSpools(): Spool[] { return [this.state.spool, ...this.otherSpools] @@ -893,7 +809,7 @@ export class Session { capture: this.capture, storage: this.storage, separator: this.settings.separator, - spools: starredFirst( + spools: ( this.allSpools().map((spool) => ({ id: spool.id, name: spool.name, @@ -901,7 +817,6 @@ export class Session { isActive: spool.id === this.state.spool.id, isDefault: spool.kind === 'default', retentionHours: spool.retentionHours, - isStarred: spool.isStarred })) ), pendingJoin: diff --git a/src/main/store/migrations.ts b/src/main/store/migrations.ts index 07d58fd..fa6c9e9 100644 --- a/src/main/store/migrations.ts +++ b/src/main/store/migrations.ts @@ -81,9 +81,16 @@ const V2 = `ALTER TABLE spools ADD COLUMN retention_hours INTEGER;` const V3 = `ALTER TABLE spools ADD COLUMN last_used_at TEXT;` /** - * A star marks a spool the user means to keep (PLAN.md 10). Default zero, so every spool that - * predates the column arrives unstarred — the safe answer, since a star is a promise the app then - * has to honour. + * Added for starred spools, **a feature since removed**. The column is deliberately kept. + * + * A migration is a record of what shipped, not a description of the current code: files in the + * wild are at v4, and rewriting history to pretend otherwise would mean either renumbering — so a + * v4 file believes it is current when it is not — or adding a v5 that rebuilds the `spools` table + * to drop one unused column. SQLite cannot drop a column without a table rebuild, and rebuilding + * every user's table to reclaim one integer per spool is real risk for no benefit they can see. + * + * So the column stays, always zero, written by nothing and read by nothing. It costs a byte per + * spool and buys the guarantee that upgrades stay boring. */ const V4 = `ALTER TABLE spools ADD COLUMN is_starred INTEGER NOT NULL DEFAULT 0;` diff --git a/src/main/store/repository.ts b/src/main/store/repository.ts index 00abb8a..5c64d26 100644 --- a/src/main/store/repository.ts +++ b/src/main/store/repository.ts @@ -19,7 +19,6 @@ interface SpoolRow { is_default: number retention_hours: number | null last_used_at: string | null - is_starred: number } interface ClipRow { @@ -35,7 +34,7 @@ interface ClipRow { export function loadSpools(database: SpoolDatabase): Spool[] { const spools = database .prepare( - `SELECT id, name, mode, cursor_clip_id, is_default, retention_hours, last_used_at, is_starred + `SELECT id, name, mode, cursor_clip_id, is_default, retention_hours, last_used_at FROM spools ORDER BY created_at` ) .all() as SpoolRow[] @@ -73,8 +72,7 @@ export function loadSpools(database: SpoolDatabase): Spool[] { clips, cursorClipId, retentionHours: row.retention_hours, - lastUsedAt: row.last_used_at, - isStarred: row.is_starred === 1 + lastUsedAt: row.last_used_at } }) } @@ -83,17 +81,16 @@ export function loadSpools(database: SpoolDatabase): Spool[] { export function saveSpool(database: SpoolDatabase, spool: Spool, now: string): void { const upsertSpool = database.prepare( `INSERT INTO spools - (id, name, mode, cursor_clip_id, is_default, retention_hours, last_used_at, is_starred, + (id, name, mode, cursor_clip_id, is_default, retention_hours, last_used_at, created_at, updated_at) VALUES (@id, @name, @mode, @cursor_clip_id, @is_default, @retention_hours, @last_used_at, - @is_starred, @now, @now) + @now, @now) ON CONFLICT (id) DO UPDATE SET name = excluded.name, mode = excluded.mode, cursor_clip_id = excluded.cursor_clip_id, retention_hours = excluded.retention_hours, last_used_at = excluded.last_used_at, - is_starred = excluded.is_starred, updated_at = excluded.updated_at` ) const deleteClips = database.prepare('DELETE FROM clips WHERE spool_id = ?') @@ -112,7 +109,6 @@ export function saveSpool(database: SpoolDatabase, spool: Spool, now: string): v is_default: spool.kind === 'default' ? 1 : 0, retention_hours: spool.retentionHours ?? null, last_used_at: spool.lastUsedAt ?? null, - is_starred: spool.isStarred ? 1 : 0, now }) diff --git a/src/main/store/store.test.ts b/src/main/store/store.test.ts index 37b69aa..dddb28c 100644 --- a/src/main/store/store.test.ts +++ b/src/main/store/store.test.ts @@ -235,9 +235,6 @@ describe('migrations (PLAN.md 7)', () => { // Columns added since default to null, which is what every rule reading them expects. expect(spool.retentionHours).toBeNull() expect(spool.lastUsedAt).toBeNull() - // Unstarred is the safe default for a spool that predates the column: a star is a promise the - // app then has to honour, and one nobody made should not appear (PLAN.md 10). - expect(spool.isStarred).toBe(false) }) it('upgrades a file written by the version M10 shipped', () => { @@ -266,7 +263,6 @@ describe('migrations (PLAN.md 7)', () => { expect(spool.mode).toBe('lifo') expect(spool.retentionHours).toBe(24) expect(spool.lastUsedAt).toBe(NOW) - expect(spool.isStarred).toBe(false) }) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 41c09bd..ae802aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -62,10 +62,7 @@ const api = { ipcRenderer.invoke(CHANNELS.revokeSourceRule, sourceApp), setConsentTimeout: (seconds: number): Promise => ipcRenderer.invoke(CHANNELS.setConsentTimeout, seconds), - /** Starred spools (PLAN.md 10). Unstarring never asks; starring can be refused. */ - setStarred: (spoolId: string, starred: boolean): Promise => - ipcRenderer.invoke(CHANNELS.setStarred, spoolId, starred), - /** Clear spools: deletes unstarred spools and spares the starred ones. */ + /** Clear spools: deletes every saved spool, keeping the default one. */ clearSpools: (): Promise => ipcRenderer.invoke(CHANNELS.clearSpools), /** The capacity advisor (PLAN.md 9): it recommends, the user decides. */ diff --git a/src/renderer/components/CapacityAdvisor.tsx b/src/renderer/components/CapacityAdvisor.tsx index d203b98..abfc47c 100644 --- a/src/renderer/components/CapacityAdvisor.tsx +++ b/src/renderer/components/CapacityAdvisor.tsx @@ -52,7 +52,7 @@ export function CapacityAdvisor({ {capacity.gated && capacity.candidates.length > 0 && (

- Largest first, since what matters here is space reclaimed. Starred spools are not listed + Largest first, since what matters here is space reclaimed. The default spool is not listed and will not be asked for.

)} diff --git a/src/renderer/components/SpoolSidebar.tsx b/src/renderer/components/SpoolSidebar.tsx index cb8fdd9..d2ef786 100644 --- a/src/renderer/components/SpoolSidebar.tsx +++ b/src/renderer/components/SpoolSidebar.tsx @@ -60,7 +60,6 @@ export function SpoolSidebar({ spools }: { spools: readonly SpoolSummary[] }): J ].join(' ')} > - {spool.isStarred && *} {spool.name} {spool.isActive && active} @@ -68,16 +67,6 @@ export function SpoolSidebar({ spools }: { spools: readonly SpoolSummary[] }): J
- {/* Starring is the commitment and can be refused; unstarring never asks. */} - {!spool.isDefault && ( - - )} {/* No Delete for the default spool: something has to catch the next copy. */} {!spool.isDefault && - !spool.isStarred && (confirmingDelete === spool.id ? ( <> diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 31ece53..527672c 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -31,7 +31,6 @@ declare global { setHotkey(action: HotkeyAction, accelerator: string): Promise resetHotkey(action: HotkeyAction): Promise resumeCapture(): Promise - setStarred(spoolId: string, starred: boolean): Promise clearSpools(): Promise deleteSpools(spoolIds: readonly string[]): Promise clearSpool(spoolId: string): Promise diff --git a/src/renderer/helpers/SpoolSidebarHelper.test.ts b/src/renderer/helpers/SpoolSidebarHelper.test.ts index 9912ab3..abddf9c 100644 --- a/src/renderer/helpers/SpoolSidebarHelper.test.ts +++ b/src/renderer/helpers/SpoolSidebarHelper.test.ts @@ -7,32 +7,31 @@ const spool = (over: Partial & { id: string }): SpoolSummary => ({ count: 0, isActive: false, isDefault: false, - isStarred: false, retentionHours: null, ...over }) -describe('clearSpoolsLabel (PLAN.md 10)', () => { - it('states what it spares, so the button can be trusted without checking first', () => { +describe('clearSpoolsLabel (PLAN.md 9)', () => { + it('names how many it will take, so the button can be trusted without checking first', () => { const spools = [ spool({ id: 'default', isDefault: true }), - ...Array.from({ length: 12 }, (_, i) => spool({ id: `plain-${i}` })), - ...Array.from({ length: 3 }, (_, i) => spool({ id: `starred-${i}`, isStarred: true })) + ...Array.from({ length: 12 }, (_, i) => spool({ id: `plain-${i}` })) ] - expect(clearSpoolsLabel(spools)).toBe('Clear 12 spools \u00b7 3 starred kept') - }) - - it('says nothing about starred spools when there are none', () => { - expect(clearSpoolsLabel([spool({ id: 'one' }), spool({ id: 'two' })])).toBe('Clear 2 spools') + expect(clearSpoolsLabel(spools)).toBe('Clear 12 spools') }) it('reads singular for one spool', () => { expect(clearSpoolsLabel([spool({ id: 'only' })])).toBe('Clear 1 spool') }) - it('offers nothing when there is nothing to clear', () => { + it('offers nothing when only the default spool is left', () => { expect(clearSpoolsLabel([spool({ id: 'default', isDefault: true })])).toBeNull() - expect(clearSpoolsLabel([spool({ id: 'starred', isStarred: true })])).toBeNull() + }) + + // The count and the action have to agree. They did not once: the label counted the active spool + // and the action then skipped it, so "Clear 1 spool" did nothing at all. + it('counts the active spool, which is cleared like any other', () => { + expect(clearSpoolsLabel([spool({ id: 'only', isActive: true })])).toBe('Clear 1 spool') }) }) diff --git a/src/renderer/helpers/SpoolSidebarHelper.ts b/src/renderer/helpers/SpoolSidebarHelper.ts index cd25cd4..bd6d5f8 100644 --- a/src/renderer/helpers/SpoolSidebarHelper.ts +++ b/src/renderer/helpers/SpoolSidebarHelper.ts @@ -3,18 +3,18 @@ import type { SpoolSummary } from '../../shared/ipc' /** Pure helpers for the spool list. No React, no I/O (PLAN.md 6). */ /** - * What the Clear spools button says (PLAN.md 10). + * What the Clear spools button says (PLAN.md 9). * - * It states what it spares — "Clear 12 spools / 3 starred kept" — because the whole point of a star - * is that the user can run this without checking first. Null when there is nothing to clear, so the + * It names the number it will take, because a destructive button that does not say what it is + * about to do is one the user runs without checking. Null when there is nothing to clear, so the * button is not offered at all. + * + * The count and the action must agree. They did not once: the label counted the active spool and + * the action then skipped it, so "Clear 1 spool" did nothing. */ export function clearSpoolsLabel(spools: readonly SpoolSummary[]): string | null { - const clearing = spools.filter((spool) => !spool.isDefault && !spool.isStarred).length + const clearing = spools.filter((spool) => !spool.isDefault).length if (clearing === 0) return null - const starred = spools.filter((spool) => spool.isStarred).length - const head = `Clear ${clearing} ${clearing === 1 ? 'spool' : 'spools'}` - - return starred === 0 ? head : `${head} \u00b7 ${starred} starred kept` + return `Clear ${clearing} ${clearing === 1 ? 'spool' : 'spools'}` } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 497c5a2..7dca536 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -134,8 +134,6 @@ export interface SpoolSummary { readonly isDefault: boolean /** How long clips live here, in hours, or null to keep them until removed (PLAN.md 11, M9). */ readonly retentionHours: number | null - /** Marked to survive routine clearing, and never proposed by a capacity state (PLAN.md 10). */ - readonly isStarred: boolean } /** A joined result waiting on a yes, because it is large enough to be felt system-wide. */ @@ -233,7 +231,6 @@ export const CHANNELS = { resetHotkey: 'spool:reset-hotkey', resumeCapture: 'spool:resume-capture', deleteSpools: 'spool:delete-spools', - setStarred: 'spool:set-starred', clearSpools: 'spool:clear-spools', deleteClip: 'spool:delete-clip', clearSpool: 'spool:clear-spool'