From 1e827ed64b4e286cf53a97c769478f0fc1bb25c6 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:32:33 +0200 Subject: [PATCH 01/10] feat(nav): add the verb-first tab model Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSq6HRwmaRKAv1t2hrdkJB --- src/shared/tabs.ts | 239 +++++++++++++++++++++++++++++++++++++++++++++ test/tabs.test.ts | 66 +++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/shared/tabs.ts create mode 100644 test/tabs.test.ts diff --git a/src/shared/tabs.ts b/src/shared/tabs.ts new file mode 100644 index 0000000..d1af78f --- /dev/null +++ b/src/shared/tabs.ts @@ -0,0 +1,239 @@ +import type { FileKind, ToolId } from './types' + +// The app's navigation model: pick what you want DONE, then drop files. The +// rail is the verb; the file kind is a property of what you dropped, not a +// place you navigate to. Replaces the old category-first catalog, where the +// same verb (Convert) had to exist five times over, once per file type. + +export type TabId = + | 'convert' + | 'compress' + | 'resize' + | 'upscale' + | 'removebg' + | 'generate' + | 'tools' + +export interface Tab { + id: TabId + label: string + /** One line under the title, and on the empty state. */ + desc: string + color: string + icon: string + /** Kinds this verb can act on. Empty means the tab takes no dropped input. */ + kinds: FileKind[] + tool: ToolId +} + +export const TABS: Tab[] = [ + { + id: 'convert', + // PDF belongs here: familyFormats unions pdf + document + text into one + // family, so PDF -> DOCX/TXT/RTF is an ordinary convert. + label: 'Convert', + desc: 'Change format', + color: '#5b5bd6', + icon: 'convert', + kinds: ['image', 'video', 'audio', 'document', 'text', 'pdf', 'archive'], + tool: 'convert' + }, + { + id: 'compress', + label: 'Compress', + desc: 'Shrink file size', + color: '#f5920b', + icon: 'compress', + kinds: ['image', 'video', 'audio', 'pdf'], + tool: 'compress' + }, + { + id: 'resize', + label: 'Resize', + desc: 'Scale by percent or exact size', + color: '#22b364', + icon: 'resize', + kinds: ['image'], + tool: 'resize' + }, + { + id: 'upscale', + label: 'Upscale', + desc: 'Enlarge 2x to 4x with AI', + color: '#8b5cf6', + icon: 'upscale', + kinds: ['image'], + tool: 'upscale' + }, + { + id: 'removebg', + label: 'Remove BG', + desc: 'Cut the subject out with AI', + color: '#12b3a6', + icon: 'removebg', + kinds: ['image'], + tool: 'removebg' + }, + { + // The one tab with no dropped input: the prompt IS the input. + id: 'generate', + label: 'Generate', + desc: 'Create an image from a text prompt', + color: '#d6409f', + icon: 'image', + kinds: [], + tool: 'generate' + }, + { + // A grid, not a workspace, until a card is picked. `tool` is unused for the + // grid itself; each card names its own. + id: 'tools', + label: 'Tools', + desc: 'One-off jobs, grouped by what they act on', + color: '#6e6e73', + icon: 'tools', + kinds: [], + tool: 'pdf' + } +] + +export interface ToolCard { + id: string + label: string + desc: string + /** Section heading in the Tools grid. */ + group: string + color: string + icon: string + tool: ToolId + opKey: string + kinds: FileKind[] +} + +// A conversion that crosses a convert group is a tool, not a Convert: Convert +// works WITHIN a group, so archive-to-PDF and PDF-to-CBZ live here while +// archive repack (.cbz to .cb7) stays on the Convert tab. +export const TOOL_CARDS: ToolCard[] = [ + { + id: 'pdf-text', + label: 'Extract text', + desc: 'Save the text layer as .txt', + group: 'PDF', + color: '#ef4444', + icon: 'text', + tool: 'pdf', + opKey: 'extract-text', + kinds: ['pdf'] + }, + { + id: 'pdf-images', + label: 'Pages to PNG', + desc: 'Render each page to an image', + group: 'PDF', + color: '#ef4444', + icon: 'image', + tool: 'pdf', + opKey: 'pages-to-images', + kinds: ['pdf'] + }, + { + id: 'pdf-merge', + label: 'Merge', + desc: 'Combine PDFs into one', + group: 'PDF', + color: '#ef4444', + icon: 'convert', + tool: 'pdf', + opKey: 'merge', + kinds: ['pdf'] + }, + { + id: 'pdf-split', + label: 'Split', + desc: 'Keep only the pages you list', + group: 'PDF', + color: '#ef4444', + icon: 'resize', + tool: 'pdf', + opKey: 'split-range', + kinds: ['pdf'] + }, + { + id: 'pdf-burst', + label: 'Burst', + desc: 'Save every page separately', + group: 'PDF', + color: '#ef4444', + icon: 'upscale', + tool: 'pdf', + opKey: 'split-pages', + kinds: ['pdf'] + }, + { + id: 'pdf-extract-images', + label: 'Extract images', + desc: 'Pull out embedded images', + group: 'PDF', + color: '#ef4444', + icon: 'removebg', + tool: 'pdf', + opKey: 'extract-images', + kinds: ['pdf'] + }, + { + id: 'archive-extract', + label: 'Extract', + desc: 'Unpack into a folder', + group: 'Archives', + color: '#a16207', + icon: 'resize', + tool: 'archive', + opKey: 'extract', + kinds: ['archive'] + }, + { + id: 'archive-to-pdf', + label: 'Archive to PDF', + desc: 'Comic archive becomes a PDF', + group: 'Archives', + color: '#a16207', + icon: 'pdf', + tool: 'archive', + opKey: 'to-pdf', + kinds: ['archive'] + }, + { + id: 'pdf-to-cbz', + label: 'PDF to CBZ', + desc: 'Pack pages as a comic archive', + group: 'Archives', + color: '#a16207', + icon: 'archive', + tool: 'archive', + opKey: 'from-pdf', + kinds: ['pdf'] + } +] + +export function tabById(id: TabId): Tab { + return TABS.find((t) => t.id === id) ?? TABS[0] +} + +export function tabAccepts(id: TabId, kind: FileKind): boolean { + return tabById(id).kinds.includes(kind) +} + +export function toolCardById(id: string): ToolCard | undefined { + return TOOL_CARDS.find((c) => c.id === id) +} + +/** Tool cards in grid order, grouped by their section heading. */ +export function toolGroups(): { name: string; cards: ToolCard[] }[] { + const out: { name: string; cards: ToolCard[] }[] = [] + for (const c of TOOL_CARDS) { + const g = out.find((x) => x.name === c.group) + if (g) g.cards.push(c) + else out.push({ name: c.group, cards: [c] }) + } + return out +} diff --git a/test/tabs.test.ts b/test/tabs.test.ts new file mode 100644 index 0000000..7a25123 --- /dev/null +++ b/test/tabs.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { TABS, TOOL_CARDS, tabAccepts, tabById, toolCardById, toolGroups } from '@shared/tabs' + +describe('tab model', () => { + it('lists the seven verbs in rail order', () => { + expect(TABS.map((t) => t.id)).toEqual([ + 'convert', + 'compress', + 'resize', + 'upscale', + 'removebg', + 'generate', + 'tools' + ]) + }) + + it('accepts the kinds each verb can actually do', () => { + expect(tabAccepts('convert', 'image')).toBe(true) + expect(tabAccepts('convert', 'archive')).toBe(true) + // PDF is a real convert source today: familyFormats puts pdf, document and + // text in one family, so PDF -> DOCX/TXT/RTF works. + expect(tabAccepts('convert', 'pdf')).toBe(true) + expect(tabAccepts('compress', 'pdf')).toBe(true) + expect(tabAccepts('compress', 'archive')).toBe(false) + expect(tabAccepts('upscale', 'image')).toBe(true) + expect(tabAccepts('upscale', 'video')).toBe(false) + expect(tabAccepts('resize', 'image')).toBe(true) + expect(tabAccepts('removebg', 'image')).toBe(true) + }) + + it('gives Generate no input kinds at all', () => { + expect(tabById('generate').kinds).toEqual([]) + expect(tabAccepts('generate', 'image')).toBe(false) + }) + + it('routes every tab to a real engine tool', () => { + const tools = [ + 'convert', + 'compress', + 'resize', + 'upscale', + 'removebg', + 'generate', + 'archive', + 'pdf' + ] + for (const t of TABS) expect(tools, t.id).toContain(t.tool) + }) + + it('groups tool cards by what they act on', () => { + expect(toolGroups().map((g) => g.name)).toEqual(['PDF', 'Archives']) + }) + + it('keeps cross-group conversions in Tools, not Convert', () => { + expect(toolCardById('archive-to-pdf')?.tool).toBe('archive') + expect(toolCardById('archive-to-pdf')?.opKey).toBe('to-pdf') + expect(toolCardById('pdf-to-cbz')?.opKey).toBe('from-pdf') + expect(toolCardById('archive-extract')?.opKey).toBe('extract') + }) + + it('gives every tool card a unique id and an accepted kind', () => { + const ids = TOOL_CARDS.map((c) => c.id) + expect(new Set(ids).size).toBe(ids.length) + for (const c of TOOL_CARDS) expect(c.kinds.length, c.id).toBeGreaterThan(0) + }) +}) From 737a1ca6b6c6a460a157539b3289ada917780690 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:37:06 +0200 Subject: [PATCH 02/10] feat(nav): rekey queues to tabs, scope selection to one convert group Range selection already enforced the group rule; toggle did not, which only stayed invisible while a queue held one file type. convertGroup gains an archive branch so a .cbz cannot batch with a .docx. v1 sessions migrate their files into the tab shape rather than being discarded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSq6HRwmaRKAv1t2hrdkJB --- src/renderer/src/state.ts | 339 ++++++++++++++++++++--------------- src/shared/convert.ts | 11 +- src/shared/tabs.ts | 24 ++- test/session-migrate.test.ts | 118 ++++++++++++ test/tabs.test.ts | 25 ++- test/verb-state.test.ts | 139 ++++++++++++++ 6 files changed, 497 insertions(+), 159 deletions(-) create mode 100644 test/session-migrate.test.ts create mode 100644 test/verb-state.test.ts diff --git a/src/renderer/src/state.ts b/src/renderer/src/state.ts index ef0742e..ef67614 100644 --- a/src/renderer/src/state.ts +++ b/src/renderer/src/state.ts @@ -2,18 +2,24 @@ import type { FileInfo, JobEvent, JobOptions, ToolId } from '@shared/types' import { convertGroup } from '@shared/convert' import { BG_DEFAULTS } from '@shared/removebg' import { GEN_DEFAULTS } from '@shared/generate' -import { - defaultOperation, - findOperation, - operationsFor, - workspaceKey, - type CategoryId, - type WorkspaceKey -} from '@shared/catalog' +import { TABS, engineFor, toolCardById, type TabId } from '@shared/tabs' /** The batch group a file belongs to (files that convert together). */ export const groupOf = (f: FileInfo): string => convertGroup(f.kind, f.ext) +/** A workspace's queue key: the tab, or the open Tools card's own workspace. */ +export type QueueKey = string +export function queueKey(tab: TabId, activeTool: string | null): QueueKey { + return tab === 'tools' && activeTool ? `tools:${activeTool}` : tab +} + +/** Options live per (workspace, convert group): one Convert tab holds an image + * target and a video target side by side. Keyed by GROUP, not kind, so a pdf + * and a docx share one option set exactly as they do today. */ +export function optionsKey(tab: TabId, activeTool: string | null, group: string): string { + return `${queueKey(tab, activeTool)}:${group}` +} + export type ItemStatus = 'ready' | 'queued' | 'running' | 'done' | 'failed' | 'canceled' export interface QueueItem { @@ -67,26 +73,20 @@ export interface QueueState { } export interface AppState { - /** The file type selected in the left rail. */ - category: CategoryId - /** The operation this workspace performs. Always set: a category opens - * directly on its default operation, with no intermediate chooser. */ - operation: string - /** One queue per FILE TYPE, shared across that type's operations. Files added - * while converting images are still there after switching to compress: the - * queue belongs to the category, not the operation. */ - queues: Partial> - /** Options are per (category, operation): converting and compressing images - * are configured separately even though they share the same files. */ - options: Record - /** The last operation chosen in each category, so switching away and back - * returns to the mode you were using instead of the category default. */ - lastOperation: Partial> -} - -/** The queue key (the file type) and the options key (file type + operation). */ -export function optionsKey(state: AppState): WorkspaceKey { - return workspaceKey(state.category, state.operation) + /** The verb selected in the left rail. */ + tab: TabId + /** Which Tools card is open, or null for the Tools grid. Only meaningful + * while `tab` is 'tools'. */ + activeTool: string | null + /** One queue per WORKSPACE: a tab, or `tools:`. Files belong to the + * verb you are performing, and a tab's queue may hold several convert groups + * at once (images and video in the same Convert queue). */ + queues: Partial> + /** Options are per (workspace, convert group): converting images and + * converting video are configured separately inside one Convert tab. */ + options: Record + /** The last Tools card opened, so returning to Tools lands where you were. */ + lastTool: string | null } export const DEFAULT_OPTIONS: Record = { @@ -123,29 +123,29 @@ export const DEFAULT_OPTIONS: Record = { export const emptyQueue = (): QueueState => ({ items: [], selected: [], anchor: null }) -/** A fresh workspace's options: its tool's defaults, plus the operation's verb - * when its tool carries several (the pdf and archive tools both do). */ -export function defaultOptionsFor(category: CategoryId, opId: string): JobOptions { - const op = findOperation(category, opId) - if (!op) return {} - const base = DEFAULT_OPTIONS[op.tool] ?? {} - return op.opKey ? { ...base, op: op.opKey } : { ...base } +/** A fresh workspace's options for one convert group: the engine tool's + * defaults, plus the verb when that tool carries several (pdf and archive both + * do). The engine is resolved per group because the Convert tab runs the + * archive tool for a .cbz and the convert tool for everything else. */ +export function defaultOptionsFor( + tab: TabId, + activeTool: string | null, + group: string +): JobOptions { + const card = activeTool ? toolCardById(activeTool) : null + const { tool, op } = engineFor(tab, group, card) + const base = DEFAULT_OPTIONS[tool] ?? {} + return op ? { ...base, op } : { ...base } } -const FIRST_CATEGORY: CategoryId = 'images' -const FIRST_OPERATION = defaultOperation(FIRST_CATEGORY) +const FIRST_TAB: TabId = 'convert' export const initialState: AppState = { - category: FIRST_CATEGORY, - operation: FIRST_OPERATION, - queues: { [FIRST_CATEGORY]: emptyQueue() }, - options: { - [workspaceKey(FIRST_CATEGORY, FIRST_OPERATION)]: defaultOptionsFor( - FIRST_CATEGORY, - FIRST_OPERATION - ) - }, - lastOperation: {} + tab: FIRST_TAB, + activeTool: null, + queues: { [FIRST_TAB]: emptyQueue() }, + options: {}, + lastTool: null } let counter = 0 @@ -155,11 +155,11 @@ export function newId(): string { } export type Action = - | { type: 'setCategory'; category: CategoryId } - | { type: 'setOperation'; operation: string } - | { type: 'setOption'; key: string; value: string | number | boolean } - | { type: 'addItems'; files: FileInfo[]; category: CategoryId } - | { type: 'addSources'; items: QueueItem[]; category: CategoryId } + | { type: 'setTab'; tab: TabId } + | { type: 'setActiveTool'; tool: string | null } + | { type: 'setOption'; group: string; key: string; value: string | number | boolean } + | { type: 'addItems'; files: FileInfo[]; key: QueueKey } + | { type: 'addSources'; items: QueueItem[]; key: QueueKey } | { type: 'setThumb'; id: string; thumb: string | null } | { type: 'dismiss'; id: string; column: 'input' | 'output' } | { type: 'markQueued'; ids: string[]; options?: JobOptions } @@ -170,8 +170,9 @@ export type Action = // --- Session persistence --------------------------------------------------- -/** Bump when AppState's persisted shape changes so old sessions are discarded. */ -const SESSION_VERSION = 1 +/** Bump when AppState's persisted shape changes. v1 (category-keyed) is not + * discarded but MIGRATED: a user mid-batch must not lose their files. */ +const SESSION_VERSION = 2 /** Normalize a queue item for persistence/restore: drop the (reloadable) thumb * and settle any in-flight status, since a run doesn't survive a restart. */ @@ -208,10 +209,10 @@ export function sessionSnapshot(state: AppState, genResults: string[]): unknown } return { version: SESSION_VERSION, - // The category and its operation are deliberately NOT persisted as such: - // the app always launches into the first category, and each category's - // sub-page rides in lastOperation (kept current by setOperation). - lastOperation: { ...state.lastOperation, [state.category]: state.operation }, + // The tab is deliberately NOT persisted: the app always launches into the + // first verb. What IS remembered is the last Tools card, so returning to + // Tools lands where you were. + lastTool: state.lastTool, options: state.options, queues, genResults: genResults.slice(0, MAX_GEN) @@ -233,67 +234,110 @@ function isValidItem(i: unknown): i is QueueItem { } interface PersistedSession { - lastOperation: Partial> - options: Record + lastTool: string | null + options: Record queues: AppState['queues'] genResults: string[] } +/** Old category ids and the verb each one's operations mapped to. Used only by + * the v1 migration. */ +const V1_OP_TO_TAB: Record = { + convert: 'convert', + compress: 'compress', + resize: 'resize', + upscale: 'upscale', + removebg: 'removebg', + generate: 'generate', + // Every PDF and archive verb became a Tools card. + 'extract-text': 'tools', + 'pages-to-images': 'tools', + merge: 'tools', + 'split-range': 'tools', + 'split-pages': 'tools', + 'extract-images': 'tools', + 'to-cbz': 'tools', + extract: 'tools', + 'to-pdf': 'tools' +} + +/** + * Move a v1 (category-keyed) session into the tab-keyed shape. Files are the + * valuable part of a session and must survive the upgrade; options are dropped + * because they were keyed by an axis that no longer exists, and re-deriving + * them per group would guess wrong more often than it would help. + */ +export function migrateV1Queues(raw: Record): AppState['queues'] { + const lastOperation = (raw.lastOperation ?? {}) as Record + const oldQueues = (raw.queues ?? {}) as Record + const out: AppState['queues'] = {} + for (const [cat, q] of Object.entries(oldQueues)) { + if (!q) continue + const verb = lastOperation[cat] + // A Tools verb has no single home queue, so its files land in Convert + // rather than being dropped on the floor. + const mapped = verb ? V1_OP_TO_TAB[verb] : undefined + const tab: TabId = mapped && mapped !== 'tools' ? mapped : 'convert' + const items = (q.items ?? []).filter(isValidItem).map(normalizeItem) + const cur = out[tab] ?? emptyQueue() + const have = new Set(cur.items.map((i) => (i.isResult ? (i.outputPath ?? i.id) : i.file.path))) + const extra = items.filter((i) => !have.has(i.isResult ? (i.outputPath ?? i.id) : i.file.path)) + out[tab] = { ...cur, items: [...cur.items, ...extra] } + } + return out +} + /** Parse a persisted blob into an AppState + genResults, or null if unusable. * Trusts nothing: falls back to defaults on any missing/mismatched field. */ export function parseSession(raw: unknown): { state: AppState; genResults: string[] } | null { if (!raw || typeof raw !== 'object') return null const r = raw as Record - if (r.version !== SESSION_VERSION) return null - const p = r as unknown as PersistedSession try { - const validCat = (c: string): boolean => { - try { - return operationsFor(c as CategoryId).length > 0 - } catch { - return false + const genResults = Array.isArray(r.genResults) + ? (r.genResults as unknown[]).filter((x): x is string => typeof x === 'string') + : [] + + // v1 was keyed by file-type category. Migrate rather than discard: the + // files are a user's in-flight work, and losing them to an upgrade is a + // worse failure than resetting their format settings. + if (r.version !== SESSION_VERSION) { + return { + state: { ...initialState, queues: migrateV1Queues(r), options: {} }, + genResults } } - // Always launch into the FIRST category (owner decision: coming back to - // the remembered one felt wrong). What IS remembered is each category's - // last sub-page: Images still opens on Generate if that is where it was, - // and switching to Video lands on its own remembered operation. - const category = FIRST_CATEGORY - const remembered = (p.lastOperation ?? {})[category] - const operation = - remembered && findOperation(category, remembered) ? remembered : defaultOperation(category) - // Drop queues for categories no longer in the catalog (a removed/renamed tool). + + const p = r as unknown as PersistedSession + const validKey = (k: string): boolean => { + if (k.startsWith('tools:')) return !!toolCardById(k.slice(6)) + return TABS.some((t) => t.id === k) + } + // Always launch into the FIRST verb: coming back to the remembered one + // felt wrong (owner decision, carried over from the category rail). const queues: AppState['queues'] = {} - for (const [cat, q] of Object.entries(p.queues ?? {})) { - if (!q || !validCat(cat)) continue - queues[cat as CategoryId] = { + for (const [k, q] of Object.entries(p.queues ?? {})) { + if (!q || !validKey(k)) continue + queues[k] = { items: (q.items ?? []).filter(isValidItem).map(normalizeItem), selected: [], anchor: null } } - // Keep only options whose (category:operation) key still resolves to a real op. - const options: Record = {} + // Keep only options whose workspace still exists. + const options: Record = {} for (const [k, v] of Object.entries(p.options ?? {})) { - const idx = k.indexOf(':') - const c = idx >= 0 ? k.slice(0, idx) : '' - const o = idx >= 0 ? k.slice(idx + 1) : '' - if (validCat(c) && findOperation(c as CategoryId, o)) - options[k as WorkspaceKey] = v as JobOptions + const cut = k.lastIndexOf(':') + if (cut < 0) continue + if (validKey(k.slice(0, cut))) options[k] = v as JobOptions } - const key = workspaceKey(category, operation) - if (!options[key]) options[key] = defaultOptionsFor(category, operation) - // Keep only valid lastOperation entries. - const lastOperation: Partial> = {} - for (const [c, o] of Object.entries(p.lastOperation ?? {})) - if (validCat(c) && o && findOperation(c as CategoryId, o)) lastOperation[c as CategoryId] = o - const state: AppState = { category, operation, lastOperation, queues, options } - return { - state, - genResults: Array.isArray(p.genResults) - ? p.genResults.filter((x) => typeof x === 'string') - : [] + const lastTool = typeof p.lastTool === 'string' && toolCardById(p.lastTool) ? p.lastTool : null + const state: AppState = { + ...initialState, + lastTool, + queues: { ...queues, [FIRST_TAB]: queues[FIRST_TAB] ?? emptyQueue() }, + options } + return { state, genResults } } catch { return null } @@ -321,14 +365,14 @@ export function pruneMissing( exists: Set ): { state: AppState; genResults: string[] } { const queues: AppState['queues'] = {} - for (const [cat, q] of Object.entries(state.queues)) { + for (const [k, q] of Object.entries(state.queues)) { if (!q) continue const items = q.items.filter((it) => it.isResult ? !!it.outputPath && exists.has(it.outputPath) : !!it.file?.path && exists.has(it.file.path) ) - queues[cat as CategoryId] = { items, selected: [], anchor: null } + queues[k] = { items, selected: [], anchor: null } } return { state: { ...state, queues }, @@ -336,16 +380,15 @@ export function pruneMissing( } } -/** Replace the current workspace's queue via `fn`. A no-op on the operation - * grid, where no workspace is open. */ +/** Replace the current workspace's queue via `fn`. */ function mapQueue(state: AppState, fn: (q: QueueState) => QueueState): AppState { - return mapQueueIn(state, state.category, fn) + return mapQueueIn(state, queueKey(state.tab, state.activeTool), fn) } -/** Replace a SPECIFIC category's queue - for actions dispatched after an +/** Replace a SPECIFIC workspace's queue - for actions dispatched after an * await, which must land where they were initiated, not where the user is. */ -function mapQueueIn(state: AppState, cat: CategoryId, fn: (q: QueueState) => QueueState): AppState { - return { ...state, queues: { ...state.queues, [cat]: fn(state.queues[cat] ?? emptyQueue()) } } +function mapQueueIn(state: AppState, key: QueueKey, fn: (q: QueueState) => QueueState): AppState { + return { ...state, queues: { ...state.queues, [key]: fn(state.queues[key] ?? emptyQueue()) } } } /** @@ -355,7 +398,7 @@ function mapQueueIn(state: AppState, cat: CategoryId, fn: (q: QueueState) => Que */ function mapItemById(state: AppState, id: string, fn: (i: QueueItem) => QueueItem): AppState { const queues = { ...state.queues } - for (const [k, q] of Object.entries(queues) as [CategoryId, QueueState][]) { + for (const [k, q] of Object.entries(queues) as [QueueKey, QueueState][]) { if (q.items.some((i) => i.id === id)) { queues[k] = { ...q, items: q.items.map((i) => (i.id === id ? fn(i) : i)) } } @@ -366,6 +409,15 @@ function mapItemById(state: AppState, id: string, fn: (i: QueueItem) => QueueIte function selectInQueue(q: QueueState, id: string, mode: SelectMode): QueueState { const order = q.items.map((i) => i.id) if (mode === 'toggle') { + const clicked = q.items.find((i) => i.id === id) + const first = q.selected.length ? q.items.find((i) => i.id === q.selected[0]) : null + // One convert group at a time. The options panel describes exactly one + // target set, so ctrl+clicking another group MOVES the selection instead of + // extending it into something Run could not honour. Range already did this; + // toggle did not, which only stayed invisible while a queue held one + // file type. + if (clicked && first && groupOf(clicked.file) !== groupOf(first.file)) + return { ...q, selected: [id], anchor: id } const has = q.selected.includes(id) return { ...q, @@ -398,68 +450,56 @@ export function reducer(state: AppState, action: Action): AppState { // Merge, never replace: the window is interactive while the session // restore round-trips, so files dropped in that gap must survive it. const merged = { ...action.state, queues: { ...action.state.queues } } - for (const [cat, q] of Object.entries(state.queues) as [CategoryId, QueueState][]) { + for (const [k, q] of Object.entries(state.queues) as [QueueKey, QueueState][]) { if (!q?.items.length) continue - const restored = merged.queues[cat] + const restored = merged.queues[k] if (!restored) { - merged.queues[cat] = q + merged.queues[k] = q continue } const key = (i: QueueItem): string => (i.isResult ? (i.outputPath ?? i.id) : i.file.path) const have = new Set(restored.items.map(key)) const extra = q.items.filter((i) => !have.has(key(i))) - if (extra.length) merged.queues[cat] = { ...restored, items: [...restored.items, ...extra] } + if (extra.length) merged.queues[k] = { ...restored, items: [...restored.items, ...extra] } } return merged } - case 'setCategory': { - // Return to the mode last used in this category (if still valid), so - // Images→Video→Images lands back on your chosen operation, not the default. - const remembered = state.lastOperation[action.category] - const opId = - remembered && findOperation(action.category, remembered) - ? remembered - : defaultOperation(action.category) - const key = workspaceKey(action.category, opId) + case 'setTab': { + // Returning to Tools lands on the card you last had open; every other + // verb is a single workspace, so there is nothing to remember. + const activeTool = action.tab === 'tools' ? state.lastTool : null + const key = queueKey(action.tab, activeTool) return { ...state, - category: action.category, - operation: opId, - queues: { - ...state.queues, - [action.category]: state.queues[action.category] ?? emptyQueue() - }, - options: { - ...state.options, - [key]: state.options[key] ?? defaultOptionsFor(action.category, opId) - } + tab: action.tab, + activeTool, + queues: { ...state.queues, [key]: state.queues[key] ?? emptyQueue() } } } - case 'setOperation': { - const key = workspaceKey(state.category, action.operation) + case 'setActiveTool': { + const key = queueKey('tools', action.tool) return { ...state, - operation: action.operation, - lastOperation: { ...state.lastOperation, [state.category]: action.operation }, - options: { - ...state.options, - [key]: state.options[key] ?? defaultOptionsFor(state.category, action.operation) - } + activeTool: action.tool, + lastTool: action.tool ?? state.lastTool, + queues: { ...state.queues, [key]: state.queues[key] ?? emptyQueue() } } } case 'setOption': { - const key = optionsKey(state) + const key = optionsKey(state.tab, state.activeTool, action.group) + const base = + state.options[key] ?? defaultOptionsFor(state.tab, state.activeTool, action.group) return { ...state, - options: { ...state.options, [key]: { ...state.options[key], [action.key]: action.value } } + options: { ...state.options, [key]: { ...base, [action.key]: action.value } } } } case 'addItems': { - // The category rides ON the action: these are dispatched after an await - // (files:classify), and the user can switch category during the round - // trip - reducing against state.category filed images into whatever + // The workspace key rides ON the action: these are dispatched after an + // await (files:classify), and the user can switch tabs during the round + // trip - reducing against the current tab filed images into whatever // queue was open when the reply landed. - const cat = action.category + const cat = action.key const q = state.queues[cat] ?? emptyQueue() // Ignore input-dismissed items so re-dropping a removed file re-adds it. const seen = new Set(q.items.filter(inInput).map((i) => i.file.path)) @@ -471,12 +511,13 @@ export function reducer(state: AppState, action: Action): AppState { // convert group (the first added file's) so a batch is never cross-category. const firstGroup = groupOf(add[0].file) const ids = add.filter((i) => groupOf(i.file) === firstGroup).map((i) => i.id) + const here = cat === queueKey(state.tab, state.activeTool) return mapQueueIn(state, cat, (cur) => ({ items: [...cur.items, ...add], // Only steer the selection when the user is still LOOKING at this - // category; a background add must not clobber another queue's state. - selected: cat === state.category ? ids : cur.selected, - anchor: cat === state.category ? ids[ids.length - 1] : cur.anchor + // workspace; a background add must not clobber another queue's state. + selected: here ? ids : cur.selected, + anchor: here ? ids[ids.length - 1] : cur.anchor })) } case 'addSources': { @@ -484,7 +525,7 @@ export function reducer(state: AppState, action: Action): AppState { // origin is visible) and select them. The caller (run) already reuses an // existing input for a path that's already present, so no dedup here. if (!action.items.length) return state - return mapQueueIn(state, action.category, (cur) => ({ + return mapQueueIn(state, action.key, (cur) => ({ items: [...cur.items, ...action.items], selected: action.items.map((i) => i.id), anchor: action.items[action.items.length - 1].id @@ -537,7 +578,7 @@ export function reducer(state: AppState, action: Action): AppState { // checkmark persists until the next run replaces it with a progress bar. if (e.status === 'done' && e.outputPath) { const queues = { ...state.queues } - for (const [k, q] of Object.entries(queues) as [CategoryId, QueueState][]) { + for (const [k, q] of Object.entries(queues) as [QueueKey, QueueState][]) { const src = q.items.find((i) => i.id === e.id && !i.isResult) if (!src) continue const result: QueueItem = { diff --git a/src/shared/convert.ts b/src/shared/convert.ts index bd27119..d136f1b 100644 --- a/src/shared/convert.ts +++ b/src/shared/convert.ts @@ -116,11 +116,18 @@ export function defaultTargetExt(kind: FileKind, srcExt: string): string | null /** * The batch-conversion group a file belongs to: files in the same group share a * target-format set and can be multi-selected + converted together. Word docs, - * plain text, and PDF all share one 'doc' group; spreadsheets and slides get - * their own (their targets differ). + * plain text, and PDF all share one 'doc' group; spreadsheets, slides and + * archives get their own (their targets differ). + * + * This is also the app's NAVIGATION grouping axis: the queue groups by it and + * a selection may never span two groups, because one options panel can only + * describe one target set. */ export function convertGroup(kind: FileKind, ext: string): string { if (kind === 'image' || kind === 'video' || kind === 'audio') return kind + // Archives share no target set with documents. Without this branch the + // fall-through below returns 'doc' and a .cbz batches with a .docx. + if (kind === 'archive') return 'archive' const e = normalizeExt(ext) if (SHEET_EXTS.includes(e)) return 'sheet' if (SLIDE_EXTS.includes(e)) return 'slide' diff --git a/src/shared/tabs.ts b/src/shared/tabs.ts index d1af78f..86d4154 100644 --- a/src/shared/tabs.ts +++ b/src/shared/tabs.ts @@ -6,13 +6,7 @@ import type { FileKind, ToolId } from './types' // same verb (Convert) had to exist five times over, once per file type. export type TabId = - | 'convert' - | 'compress' - | 'resize' - | 'upscale' - | 'removebg' - | 'generate' - | 'tools' + 'convert' | 'compress' | 'resize' | 'upscale' | 'removebg' | 'generate' | 'tools' export interface Tab { id: TabId @@ -215,6 +209,22 @@ export const TOOL_CARDS: ToolCard[] = [ } ] +/** + * The engine tool (and verb) that actually performs a tab's work for one convert + * group. The mapping is not 1:1: the Convert tab runs the `convert` tool for + * images, video, audio and documents, but an archive repack is the `archive` + * tool with op `repack`. A tool card names its own tool and op outright. + */ +export function engineFor( + tab: TabId, + group: string, + card?: ToolCard | null +): { tool: ToolId; op?: string } { + if (card) return { tool: card.tool, op: card.opKey } + if (tab === 'convert' && group === 'archive') return { tool: 'archive', op: 'repack' } + return { tool: tabById(tab).tool } +} + export function tabById(id: TabId): Tab { return TABS.find((t) => t.id === id) ?? TABS[0] } diff --git a/test/session-migrate.test.ts b/test/session-migrate.test.ts new file mode 100644 index 0000000..21f9091 --- /dev/null +++ b/test/session-migrate.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { parseSession } from '../src/renderer/src/state' + +const item = (p: string, kind = 'image'): unknown => ({ + id: p, + file: { path: p, name: p, ext: '.png', kind, size: 1 }, + thumb: null, + status: 'ready', + percent: 0 +}) + +// A v1 blob was keyed by file-type category. Files are a user's in-flight work, +// so an upgrade migrates them rather than discarding the session. +describe('v1 session migration', () => { + it('moves each category queue to the tab its last operation names', () => { + const r = parseSession({ + version: 1, + lastOperation: { images: 'upscale', video: 'compress' }, + queues: { + images: { items: [item('a.png')] }, + video: { items: [item('v.mp4', 'video')] } + }, + options: { 'images:convert': { format: '.webp' } } + })! + expect(r.state.queues.upscale!.items.map((i) => i.file.path)).toEqual(['a.png']) + expect(r.state.queues.compress!.items.map((i) => i.file.path)).toEqual(['v.mp4']) + }) + + it('merges two categories that land on the same tab, without duplicates', () => { + const r = parseSession({ + version: 1, + lastOperation: { images: 'convert', video: 'convert' }, + queues: { + images: { items: [item('a.png')] }, + video: { items: [item('a.png'), item('v.mp4', 'video')] } + } + })! + expect(r.state.queues.convert!.items.map((i) => i.file.path).sort()).toEqual(['a.png', 'v.mp4']) + }) + + it('lands a Tools verb in Convert rather than dropping its files', () => { + const r = parseSession({ + version: 1, + lastOperation: { pdf: 'merge' }, + queues: { pdf: { items: [item('doc.pdf', 'pdf')] } } + })! + expect(r.state.queues.convert!.items).toHaveLength(1) + }) + + it('falls back to Convert for a category with no remembered operation', () => { + const r = parseSession({ + version: 1, + queues: { documents: { items: [item('d.docx', 'document')] } } + })! + expect(r.state.queues.convert!.items).toHaveLength(1) + }) + + it('drops v1 options rather than guessing a group for them', () => { + const r = parseSession({ + version: 1, + queues: {}, + options: { 'images:convert': { format: '.webp' } } + })! + expect(Object.keys(r.state.options)).toHaveLength(0) + }) + + it('keeps generated results across the migration', () => { + const r = parseSession({ + version: 1, + queues: {}, + genResults: ['C:\\gen1.png', 'C:\\gen2.png'] + })! + expect(r.genResults).toEqual(['C:\\gen1.png', 'C:\\gen2.png']) + }) + + it('drops a malformed item instead of rendering it', () => { + const r = parseSession({ + version: 1, + queues: { images: { items: [item('a.png'), { id: 'broken' }] } } + })! + expect(r.state.queues.convert!.items).toHaveLength(1) + }) +}) + +describe('v2 session', () => { + it('restores tab-keyed queues and options', () => { + const r = parseSession({ + version: 2, + lastTool: 'pdf-merge', + queues: { compress: { items: [item('a.png')] } }, + options: { 'compress:image': { quality: 70 } }, + genResults: [] + })! + expect(r.state.queues.compress!.items).toHaveLength(1) + expect(r.state.options['compress:image']).toEqual({ quality: 70 }) + expect(r.state.lastTool).toBe('pdf-merge') + }) + + it('always launches into the first verb, not the remembered one', () => { + const r = parseSession({ version: 2, queues: { upscale: { items: [] } }, options: {} })! + expect(r.state.tab).toBe('convert') + }) + + it('drops a queue whose workspace no longer exists', () => { + const r = parseSession({ + version: 2, + queues: { 'tools:gone-forever': { items: [item('a.png')] }, convert: { items: [] } }, + options: { 'tools:gone-forever:image': { x: 1 } } + })! + expect(r.state.queues['tools:gone-forever']).toBeUndefined() + expect(Object.keys(r.state.options)).toHaveLength(0) + }) + + it('returns null for junk', () => { + expect(parseSession(null)).toBeNull() + expect(parseSession('nope')).toBeNull() + }) +}) diff --git a/test/tabs.test.ts b/test/tabs.test.ts index 7a25123..df3c3d8 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { TABS, TOOL_CARDS, tabAccepts, tabById, toolCardById, toolGroups } from '@shared/tabs' +import { + TABS, + TOOL_CARDS, + engineFor, + tabAccepts, + tabById, + toolCardById, + toolGroups +} from '@shared/tabs' describe('tab model', () => { it('lists the seven verbs in rail order', () => { @@ -58,6 +66,21 @@ describe('tab model', () => { expect(toolCardById('archive-extract')?.opKey).toBe('extract') }) + it('routes an archive Convert to the archive engine, not the convert one', () => { + // The tab-to-tool mapping is not 1:1: repacking a .cbz is the archive tool + // with op 'repack', even though the tab is Convert. + expect(engineFor('convert', 'archive')).toEqual({ tool: 'archive', op: 'repack' }) + expect(engineFor('convert', 'image')).toEqual({ tool: 'convert' }) + expect(engineFor('compress', 'doc')).toEqual({ tool: 'compress' }) + }) + + it('lets a tool card name its own tool and verb', () => { + expect(engineFor('tools', 'pdf', toolCardById('pdf-merge'))).toEqual({ + tool: 'pdf', + op: 'merge' + }) + }) + it('gives every tool card a unique id and an accepted kind', () => { const ids = TOOL_CARDS.map((c) => c.id) expect(new Set(ids).size).toBe(ids.length) diff --git a/test/verb-state.test.ts b/test/verb-state.test.ts new file mode 100644 index 0000000..0b61431 --- /dev/null +++ b/test/verb-state.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { convertGroup } from '@shared/convert' +import { + emptyQueue, + initialState, + optionsKey, + queueKey, + reducer, + type AppState +} from '../src/renderer/src/state' +import type { FileInfo } from '@shared/types' + +const file = (name: string, kind: FileInfo['kind'], ext: string): FileInfo => ({ + path: `C:\\${name}`, + name, + ext, + kind, + size: 1 +}) + +function seeded(): AppState { + const s: AppState = { + ...initialState, + tab: 'convert', + activeTool: null, + queues: { + convert: { + ...emptyQueue(), + items: [ + { + id: 'a', + file: file('a.png', 'image', '.png'), + thumb: null, + status: 'ready', + percent: 0 + }, + { + id: 'b', + file: file('b.png', 'image', '.png'), + thumb: null, + status: 'ready', + percent: 0 + }, + { + id: 'v', + file: file('v.mp4', 'video', '.mp4'), + thumb: null, + status: 'ready', + percent: 0 + } + ] + } + } + } + return s +} + +describe('keys', () => { + it('keys a plain tab queue by the tab id', () => { + expect(queueKey('convert', null)).toBe('convert') + }) + + it('keys a tool workspace by its tool card', () => { + expect(queueKey('tools', 'pdf-merge')).toBe('tools:pdf-merge') + }) + + it('keys options by queue and convert group', () => { + expect(optionsKey('convert', null, 'image')).toBe('convert:image') + expect(optionsKey('convert', null, 'video')).toBe('convert:video') + // A pdf and a docx share the 'doc' group, so they share one option set. + expect(optionsKey('convert', null, 'doc')).toBe('convert:doc') + expect(optionsKey('tools', 'pdf-merge', 'doc')).toBe('tools:pdf-merge:doc') + }) +}) + +describe('convertGroup', () => { + it('keeps pdf, document and text in one batch, as today', () => { + expect(convertGroup('pdf', '.pdf')).toBe('doc') + expect(convertGroup('document', '.docx')).toBe('doc') + expect(convertGroup('text', '.txt')).toBe('doc') + }) + + it('gives archives their own group instead of falling through to doc', () => { + expect(convertGroup('archive', '.cbz')).toBe('archive') + expect(convertGroup('archive', '.rar')).toBe('archive') + }) +}) + +describe('one-group selection', () => { + it('extends the selection within a group', () => { + let s = seeded() + s = reducer(s, { type: 'select', id: 'a', mode: 'single' }) + s = reducer(s, { type: 'select', id: 'b', mode: 'toggle' }) + expect([...s.queues.convert!.selected].sort()).toEqual(['a', 'b']) + }) + + it('replaces the selection when a different group is toggled in', () => { + let s = seeded() + s = reducer(s, { type: 'select', id: 'a', mode: 'single' }) + s = reducer(s, { type: 'select', id: 'b', mode: 'toggle' }) + s = reducer(s, { type: 'select', id: 'v', mode: 'toggle' }) + // Ctrl+clicking a video does not append it to an image selection: it + // becomes the selection, because one options panel cannot describe both. + expect(s.queues.convert!.selected).toEqual(['v']) + }) + + it('still lets a toggle deselect within the same group', () => { + let s = seeded() + s = reducer(s, { type: 'select', id: 'a', mode: 'single' }) + s = reducer(s, { type: 'select', id: 'b', mode: 'toggle' }) + s = reducer(s, { type: 'select', id: 'b', mode: 'toggle' }) + expect(s.queues.convert!.selected).toEqual(['a']) + }) + + it('never lets a range selection span groups', () => { + let s = seeded() + s = reducer(s, { type: 'select', id: 'a', mode: 'single' }) + // Shift-clicking the video extends through the range but keeps only the + // anchor's group, so the two images come along and the video does not. + s = reducer(s, { type: 'select', id: 'v', mode: 'range' }) + expect(s.queues.convert!.selected).toEqual(['a', 'b']) + }) +}) + +describe('tab switching', () => { + it('creates the tab queue and its options on first visit', () => { + const s = reducer(initialState, { type: 'setTab', tab: 'compress' }) + expect(s.tab).toBe('compress') + expect(s.queues.compress).toBeDefined() + }) + + it('clears the open tool when leaving Tools', () => { + let s = reducer(initialState, { type: 'setTab', tab: 'tools' }) + s = reducer(s, { type: 'setActiveTool', tool: 'pdf-merge' }) + expect(s.activeTool).toBe('pdf-merge') + s = reducer(s, { type: 'setTab', tab: 'convert' }) + expect(s.activeTool).toBeNull() + }) +}) From 6dbd1ad0f58e17bd157e4f050e00fc561cbeb762 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:48:42 +0200 Subject: [PATCH 03/10] feat(nav): drive the whole workspace from the verb rail Rail becomes the verb; the queue belongs to what you are doing and can hold several convert groups at once, with a header per group and the options panel scoped to the selected one. Drops the OperationSwitcher (the rail names the operation now) for a scope chip that says what Run will act on. Adds the Tools grid and its per-card workspaces. shared/catalog.ts is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSq6HRwmaRKAv1t2hrdkJB --- e2e/workflows.spec.ts | 54 ++-- src/renderer/src/App.tsx | 183 ++++++++------ src/renderer/src/components/Icon.tsx | 2 + .../src/components/OperationSwitcher.tsx | 90 ------- .../src/components/OperationTitle.tsx | 46 +++- src/renderer/src/components/OptionsPanel.tsx | 103 ++++++-- src/renderer/src/components/Queue.tsx | 52 ++-- .../{CategoryRail.tsx => TabRail.tsx} | 49 ++-- src/renderer/src/components/ToolsGrid.tsx | 41 ++++ src/renderer/src/components/queueGroups.ts | 65 +++++ src/shared/catalog.ts | 231 ------------------ test/archive-catalog-nav.test.ts | 34 --- test/catalog-tools.test.ts | 23 -- test/queue-groups.test.ts | 51 ++++ test/queues.test.ts | 152 ++++++------ test/session-persist.test.ts | 52 ++-- test/tabs-registry.test.ts | 35 +++ 17 files changed, 610 insertions(+), 653 deletions(-) delete mode 100644 src/renderer/src/components/OperationSwitcher.tsx rename src/renderer/src/components/{CategoryRail.tsx => TabRail.tsx} (88%) create mode 100644 src/renderer/src/components/ToolsGrid.tsx create mode 100644 src/renderer/src/components/queueGroups.ts delete mode 100644 src/shared/catalog.ts delete mode 100644 test/archive-catalog-nav.test.ts delete mode 100644 test/catalog-tools.test.ts create mode 100644 test/queue-groups.test.ts create mode 100644 test/tabs-registry.test.ts diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts index 0fb1733..0f667d9 100644 --- a/e2e/workflows.spec.ts +++ b/e2e/workflows.spec.ts @@ -169,35 +169,39 @@ test.afterAll(async () => { // --- UI navigation ----------------------------------------------------------- -test('every category opens its workspace with the right operations', async () => { - const expected: Record = { - Images: ['Convert', 'Compress', 'Resize', 'Remove Background', 'Upscale', 'Generate'], - Video: ['Convert', 'Compress'], - Audio: ['Convert', 'Compress'], - PDF: [ - 'Extract text', - 'Pages to PNG', - 'Merge', - 'Split', - 'Burst', - 'Extract images', - 'To CBZ', - 'Compress' - ], - Documents: ['Convert'], - Archives: ['Convert', 'Extract', 'To PDF'] +test('the rail lists every verb and opens its workspace', async () => { + for (const verb of [ + 'Convert', + 'Compress', + 'Resize', + 'Upscale', + 'Remove BG', + 'Generate', + 'Tools' + ]) { + await page.locator(`button:has-text("${verb}")`).first().click() + await expect(page.locator('h1', { hasText: verb }).first()).toBeVisible() } - for (const [category, ops] of Object.entries(expected)) { - await page.locator(`button:has-text("${category}")`).first().click() - // The operation switcher lists every operation of this category. - await page.locator('aside button').first().click() // open the switcher - for (const op of ops) { - await expect(page.locator(`text=${op}`).first()).toBeVisible() - } - await page.keyboard.press('Escape') + await page.locator('button:has-text("Convert")').first().click() +}) + +test('Tools groups its one-off verbs and opens one as a workspace', async () => { + await page.locator('button:has-text("Tools")').first().click() + for (const t of ['Extract text', 'Merge', 'Burst', 'Archive to PDF', 'PDF to CBZ']) { + await expect(page.locator(`text=${t}`).first()).toBeVisible() } + await page.locator('button:has-text("Merge")').first().click() + // A tool workspace is an ordinary queue titled with the tool's name. + await expect(page.locator('h1', { hasText: 'Merge' }).first()).toBeVisible() + await expect(page.locator('text=Input').first()).toBeVisible() + await page.locator('button:has-text("Convert")').first().click() }) +// NOTE: the mixed-kind queue, its group headers and the kind-scoped options +// panel are covered by unit tests (queue-groups, verb-state), not here. Adding +// files to the UI needs a real OS drop or a native file dialog, and neither is +// drivable from Playwright without a production-only test seam. + // --- Images ------------------------------------------------------------------ test('convert: png -> jpg, webp, bmp (flattened), multi-size ico', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 052e501..1494ae9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,6 +11,7 @@ import { import type { FileInfo, FileKind, PreviewItem } from '@shared/types' import { canCompress, + convertGroup, familyFormats, isSameFormat, normalizeExt, @@ -29,6 +30,8 @@ import { reducer, initialState, optionsKey, + queueKey, + defaultOptionsFor, emptyQueue, inInput, inOutput, @@ -41,16 +44,11 @@ import { type QueueItem, type SelectMode } from './state' -import { - acceptsKind, - categoryOf, - findOperation, - operationsFor, - type CategoryId -} from '@shared/catalog' +import { engineFor, tabAccepts, tabById, toolCardById } from '@shared/tabs' import { TopBar } from './components/TopBar' -import { CategoryRail } from './components/CategoryRail' +import { TabRail } from './components/TabRail' import { OperationTitle } from './components/OperationTitle' +import { ToolsGrid } from './components/ToolsGrid' import { DropZone } from './components/DropZone' import { PromptBox } from './components/PromptBox' import type { GenerateOptions } from '@shared/generate' @@ -231,14 +229,15 @@ export default function App(): JSX.Element { return () => window.removeEventListener('beforeunload', flush) }, []) - // The queue belongs to the file type and is shared across its operations, so - // switching Convert -> Compress keeps the same files. Options are per - // operation, keyed by (category, operation). - const op = findOperation(state.category, state.operation) ?? operationsFor(state.category)[0] - const tool = op.tool - const category = categoryOf(state.category) - const cur = state.queues[state.category] ?? emptyQueue() - const curOptions = state.options[optionsKey(state)] ?? {} + // The queue belongs to the VERB: one Convert queue can hold images and video + // at once. Options are per (workspace, convert group), so the image target and + // the video target live side by side inside that one tab. + const tab = tabById(state.tab) + const card = state.activeTool ? toolCardById(state.activeTool) : null + const qKey = queueKey(state.tab, state.activeTool) + const cur = state.queues[qKey] ?? emptyQueue() + // The Tools grid is a chooser, not a workspace: no queue, no options panel. + const onToolsGrid = state.tab === 'tools' && !card // Stream job progress/terminal events into state. useEffect(() => window.filesmith.onJobEvent((e) => dispatch({ type: 'jobEvent', event: e })), []) @@ -258,6 +257,7 @@ export default function App(): JSX.Element { // (incl. exotic formats via magick), videos (ffmpeg frame), audio cover art. useEffect(() => { for (const q of Object.values(state.queues)) { + if (!q) continue for (const item of q.items) { if (item.thumb !== null || requested.current.has(item.id)) continue requested.current.add(item.id) @@ -272,6 +272,7 @@ export default function App(): JSX.Element { // input's kind — convert never crosses categories). useEffect(() => { for (const q of Object.values(state.queues)) { + if (!q) continue for (const item of q.items) { const out = item.outputPath if (!out || item.status !== 'done' || outRequested.current.has(out)) continue @@ -292,6 +293,17 @@ export default function App(): JSX.Element { const selEff = selectedItems.map(effectiveFile) const activeKind: FileKind | null = selEff.length ? selEff[0].kind : null const activeGroup: string | null = selEff.length ? groupOf(selEff[0]) : null + // Nothing selected yet: fall back to the first kind this verb accepts, so the + // options panel can still show what it would do. + const fallbackKind: FileKind = tab.kinds[0] ?? card?.kinds[0] ?? 'image' + const optGroup = activeGroup ?? convertGroup(fallbackKind, '') + // The engine is resolved per group: the Convert tab runs the archive tool for + // a .cbz and the convert tool for everything else. + const engine = engineFor(state.tab, optGroup, card) + const tool = engine.tool + const curOptions = state.options[optionsKey(state.tab, state.activeTool, optGroup)] ?? { + ...defaultOptionsFor(state.tab, state.activeTool, optGroup) + } const srcNorms = new Set(selEff.map((f) => normalizeExt(f.ext))) const srcExts = [...srcNorms] // every selected source format (for greying targets) const sourceExt: string | null = srcNorms.size === 1 ? [...srcNorms][0] : null @@ -306,14 +318,15 @@ export default function App(): JSX.Element { const runList: QueueItem[] = selectedItems.filter((i) => { if (!canRun(i)) return false const f = effectiveFile(i) - // The workspace is type-locked, so the category already guarantees the kind. - // What is left to check is whether this specific file can take this operation. - if (!acceptsKind(state.category, f.kind)) return false - if (op.tool === 'convert') { + // The queue can hold several kinds, so check this file against the verb AND + // against the selected group: Run only ever acts on one group. + if (!accepts(f.kind)) return false + if (activeGroup && groupOf(f) !== activeGroup) return false + if (tool === 'convert') { const fmt = String(curOptions.format ?? '') return toolForKind(f.kind) != null && !isSameFormat(f.ext, fmt) } - if (op.tool === 'compress') return canCompress(f.kind, f.ext) + if (tool === 'compress') return canCompress(f.kind, f.ext) return true }) @@ -327,7 +340,7 @@ export default function App(): JSX.Element { const valid = opts.some((f) => f.ext === fmt) && !isSource(fmt) if (!valid) { const def = opts.find((f) => !isSource(f.ext))?.ext - if (def) dispatch({ type: 'setOption', key: 'format', value: def }) + if (def) dispatch({ type: 'setOption', group: optGroup, key: 'format', value: def }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeKind, sourceExt, srcExts.join('|'), curOptions.format, tool]) @@ -355,14 +368,14 @@ export default function App(): JSX.Element { list.findIndex((it) => it.id === item.id) ) void window.filesmith.openPreviewWindow(toPreviewFiles(cur.items, side, outThumbs), index) - setPreviewCtx({ side, key: state.category }) + setPreviewCtx({ side, key: qKey }) } // Keep an open preview window's list in sync with the queue (it manages its // own position; a no-op in main if the window is closed). useEffect(() => { if (!previewCtx) return - const items = state.queues[previewCtx.key as CategoryId]?.items ?? [] + const items = state.queues[previewCtx.key]?.items ?? [] window.filesmith.updatePreviewList(toPreviewFiles(items, previewCtx.side, outThumbs)) }, [state.queues, outThumbs, previewCtx]) @@ -524,16 +537,22 @@ export default function App(): JSX.Element { }) } - /** Keep only what this workspace accepts. The screen promises one file type, - * so silently taking a video into the Images queue would break that promise. */ - function ofCategory(files: FileInfo[]): FileInfo[] { - return files.filter((f) => acceptsKind(state.category, f.kind)) + /** Whether this workspace can act on a kind at all. A tool card names its own + * kinds; every other tab uses the verb's. */ + function accepts(kind: FileKind): boolean { + return card ? card.kinds.includes(kind) : tabAccepts(state.tab, kind) + } + + /** Keep only what this workspace can actually do. Upscale silently swallowing + * an MP4 would promise work it cannot perform. */ + function ofTab(files: FileInfo[]): FileInfo[] { + return files.filter((f) => accepts(f.kind)) } async function browse(): Promise { - const category = state.category - const files = ofCategory(await window.filesmith.pickFiles()) - if (files.length) dispatch({ type: 'addItems', files, category }) + const key = qKey + const files = ofTab(await window.filesmith.pickFiles()) + if (files.length) dispatch({ type: 'addItems', files, key }) } async function onDrop(e: DragEvent): Promise { @@ -541,14 +560,14 @@ export default function App(): JSX.Element { setDragging(false) // Generate has no queue on screen: a file accepted here would land in an // invisible list with no feedback at all. - if (op.tool === 'generate') return + if (tool === 'generate' || onToolsGrid) return const paths = Array.from(e.dataTransfer.files) .map((f) => window.filesmith.pathForFile(f)) .filter(Boolean) if (!paths.length) return - const category = state.category - const files = ofCategory(await window.filesmith.classify(paths)) - if (files.length) dispatch({ type: 'addItems', files, category }) + const key = qKey + const files = ofTab(await window.filesmith.classify(paths)) + if (files.length) dispatch({ type: 'addItems', files, key }) } // Build a fresh Input-column source item for a path (a promoted output, or a @@ -683,7 +702,7 @@ export default function App(): JSX.Element { } async function run(): Promise { - if (op.tool === 'generate') return generate() + if (tool === 'generate') return generate() if (!runList.length) return const opts = curOptions @@ -707,7 +726,7 @@ export default function App(): JSX.Element { // Merge is N-in/1-out, so it doesn't follow the 1:1 rule: run the anchor in // place (promoting it first if it's an output) with all paths as inputs. - if (op.tool === 'pdf' && opts.op === 'merge') { + if (tool === 'pdf' && opts.op === 'merge') { if (runList.length < 2) return const paths = runList.map((i) => effectiveFile(i).path) const anchor = runList[0] @@ -715,7 +734,7 @@ export default function App(): JSX.Element { if (anchor.isResult) { const src = await makeSource(anchor) if (!src) return - dispatch({ type: 'addSources', items: [src], category: state.category }) + dispatch({ type: 'addSources', items: [src], key: qKey }) anchorId = src.id } dispatch({ type: 'markQueued', ids: [anchorId], options: opts }) @@ -745,21 +764,20 @@ export default function App(): JSX.Element { newSources.push(src) targets.push({ id: src.id, path: src.file.path }) } - if (newSources.length) - dispatch({ type: 'addSources', items: newSources, category: state.category }) + if (newSources.length) dispatch({ type: 'addSources', items: newSources, key: qKey }) if (!targets.length) return dispatch({ type: 'markQueued', ids: targets.map((t) => t.id), options: opts }) for (const t of targets) { - void window.filesmith.runJob({ id: t.id, tool: op.tool, input: t.path, options: opts }) + void window.filesmith.runJob({ id: t.id, tool, input: t.path, options: opts }) } } // Merge needs 2+ PDFs before it can run; every other op runs per selected file. - const isMerge = op.tool === 'pdf' && String(curOptions.op) === 'merge' + const isMerge = tool === 'pdf' && String(curOptions.op) === 'merge' const promptFilled = String(curOptions.prompt ?? '').trim().length > 0 const genAspect = `${Number(curOptions.width ?? 1024)} / ${Number(curOptions.height ?? 1024)}` const runCount = - op.tool === 'generate' + tool === 'generate' ? promptFilled && !genRun.running ? 1 : 0 @@ -858,21 +876,24 @@ export default function App(): JSX.Element { }) : [] - // Files waiting in each category, summed across that category's workspaces, so - // the rail shows where work is sitting even while you're looking elsewhere. + // Files waiting in each verb, so the rail shows where work is sitting even + // while you're looking elsewhere. Every Tools workspace rolls up into Tools. const counts: Record = {} - for (const [cat, q] of Object.entries(state.queues)) { - counts[cat] = q.items.filter(inInput).length + for (const [k, q] of Object.entries(state.queues)) { + if (!q) continue + const n = q.items.filter(inInput).length + const bucket = k.startsWith('tools:') ? 'tools' : k + counts[bucket] = (counts[bucket] ?? 0) + n } return (
- dispatch({ type: 'setCategory', category: c })} + onSelect={(t) => dispatch({ type: 'setTab', tab: t })} /> <> @@ -880,21 +901,31 @@ export default function App(): JSX.Element { className="flex min-w-0 flex-1 flex-col gap-4 px-7 pb-5 pt-1" onDragOver={(e) => { e.preventDefault() - if (op.tool !== 'generate') setDragging(true) + if (tool !== 'generate' && !onToolsGrid) setDragging(true) }} onDragLeave={(e) => { if (e.currentTarget === e.target) setDragging(false) }} onDrop={onDrop} > - {/* The rail names the file type; the sidebar switcher names (and - colours) the operation. This heading is just a heading. */} - - {op.tool === 'generate' ? ( + {/* The rail names the verb, so this heading just repeats it back and + carries the file count (or the open tool's name inside Tools). */} + dispatch({ type: 'setActiveTool', tool: null }) : undefined} + /> + {onToolsGrid ? ( + dispatch({ type: 'setActiveTool', tool: id })} /> + ) : tool === 'generate' ? ( <> dispatch({ type: 'setOption', key: 'prompt', value: v })} + onChange={(v) => + dispatch({ type: 'setOption', group: optGroup, key: 'prompt', value: v }) + } /> {genRun.running && (
@@ -971,12 +1002,12 @@ export default function App(): JSX.Element { <> void browse()} /> - dispatch({ type: 'setOperation', operation: id })} - options={curOptions} - activeKind={activeKind} - runKind={runKind} - fallbackKind={category.kinds[0]} - videoOutputs={videoOutputs} - upscaleOutputs={upscaleOutputs} - resizeOutputs={resizeOutputs} - sourceExt={sourceExt} - srcExts={srcExts} - runCount={runCount} - onSet={(k, v) => dispatch({ type: 'setOption', key: k, value: v })} - onRun={() => void run()} - /> + {!onToolsGrid && ( + dispatch({ type: 'setOption', group: optGroup, key: k, value: v })} + onRun={() => void run()} + /> + )}
diff --git a/src/renderer/src/components/Icon.tsx b/src/renderer/src/components/Icon.tsx index 792cd45..d6961cd 100644 --- a/src/renderer/src/components/Icon.tsx +++ b/src/renderer/src/components/Icon.tsx @@ -8,6 +8,7 @@ export type IconName = | 'removebg' | 'pdf' | 'archive' + | 'tools' | 'upload' | 'check' | 'clock' @@ -60,6 +61,7 @@ const PATHS: Record = { ), pdf: , archive: , + tools: , upload: , check: , clock: ( diff --git a/src/renderer/src/components/OperationSwitcher.tsx b/src/renderer/src/components/OperationSwitcher.tsx deleted file mode 100644 index 05b74af..0000000 --- a/src/renderer/src/components/OperationSwitcher.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useRef, useState, type JSX } from 'react' -import type { Operation } from '@shared/catalog' -import { Icon, type IconName } from './Icon' - -/** - * The operation switcher, sitting at the top of the options sidebar. It takes - * the SELECTED operation's own colour, so the mode is colour-coded and the - * control doubles as a status light: an orange card means you're compressing, a - * green one means resizing. Every other control in the panel is themed black, so - * this coloured card is unmistakably the one primary choice, not a setting. - */ -export function OperationSwitcher({ - operation, - operations, - onPick -}: { - operation: Operation - operations: Operation[] - onPick: (id: string) => void -}): JSX.Element { - const [open, setOpen] = useState(false) - const ref = useRef(null) - - useEffect(() => { - if (!open) return - const close = (e: Event): void => { - if (e instanceof KeyboardEvent && e.key !== 'Escape') return - if (e.type === 'mousedown' && ref.current?.contains(e.target as Node)) return - setOpen(false) - } - window.addEventListener('mousedown', close) - window.addEventListener('keydown', close) - return () => { - window.removeEventListener('mousedown', close) - window.removeEventListener('keydown', close) - } - }, [open]) - - return ( -
- - - {open && ( -
- {operations.map((o) => { - const on = o.id === operation.id - return ( - - ) - })} -
- )} -
- ) -} diff --git a/src/renderer/src/components/OperationTitle.tsx b/src/renderer/src/components/OperationTitle.tsx index bf7949b..002f22a 100644 --- a/src/renderer/src/components/OperationTitle.tsx +++ b/src/renderer/src/components/OperationTitle.tsx @@ -1,28 +1,50 @@ import type { JSX } from 'react' -import type { Category } from '@shared/catalog' +import { Icon } from './Icon' /** - * The page heading: the file type ("Images", "PDF"), and a file count once files - * exist. + * The page heading: the verb ("Convert", "Compress"), or the open tool's name + * inside Tools, plus a file count once files exist. * - * Not the operation. The operation is named by the coloured switcher in the - * sidebar, so the heading names the other axis (what kind of file you're working - * with) rather than repeating the mode. + * The rail already names the verb, so this heading repeats it back as the + * workspace's own title. Inside a Tools workspace it also carries the way back + * to the grid, which is the only place in the app with a second level. */ export function OperationTitle({ - category, - fileCount + title, + desc, + color, + fileCount, + onBack }: { - category: Category + title: string + desc: string + color: string fileCount: number + /** Present only inside a Tools workspace. */ + onBack?: () => void }): JSX.Element { return (
-

{category.label}

+
+ {onBack && ( + + )} +

+ {title} +

+
{/* The line always occupies its height so nothing below it shifts when a - file is added and the count appears. Empty until there's a count. */} + file is added and the count appears. Falls back to the verb's own + one-liner while the queue is empty. */}

- {fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : ''} + {fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : desc}

) diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx index 507191b..0e21ff9 100644 --- a/src/renderer/src/components/OptionsPanel.tsx +++ b/src/renderer/src/components/OptionsPanel.tsx @@ -22,9 +22,8 @@ import { RESIZE_FITS, type ResizeFit } from '@shared/resize' import { BG_DEFAULTS, BG_FILLS, type BgFill } from '@shared/removebg' import { GEN_SIZES, GEN_STYLES, GEN_MAX_COUNT, clampDim } from '@shared/generate' import { archInfoFor, type GenArch, type GenModel } from '@shared/genArch' -import type { Operation } from '@shared/catalog' import { Icon } from './Icon' -import { OperationSwitcher } from './OperationSwitcher' +import type { ToolId } from '@shared/types' import { PidInstallCard } from './PidUpscale' import { usePidStatus } from './usePidStatus' import { ComfyImportCard } from './ComfyImport' @@ -1630,12 +1629,64 @@ function GenerateOptions({ ) } +const KIND_COLOR: Record = { + image: '#5b5bd6', + video: '#e0483d', + audio: '#f5920b', + pdf: '#ef4444', + document: '#12b3a6', + text: '#12b3a6', + archive: '#a16207', + other: '#6e6e73' +} +const KIND_NOUN: Record string> = { + image: (n) => `${n} image${n === 1 ? '' : 's'}`, + video: (n) => `${n} video${n === 1 ? '' : 's'}`, + audio: (n) => `${n} audio file${n === 1 ? '' : 's'}`, + pdf: (n) => `${n} PDF${n === 1 ? '' : 's'}`, + document: (n) => `${n} document${n === 1 ? '' : 's'}`, + text: (n) => `${n} text file${n === 1 ? '' : 's'}`, + archive: (n) => `${n} archive${n === 1 ? '' : 's'}`, + other: (n) => `${n} file${n === 1 ? '' : 's'}` +} + +/** What these settings will act on. The queue can hold images and video at the + * same time, so "Run" has to say which of them it means. */ +function ScopeChip({ + kind, + count, + known +}: { + kind: FileKind + count: number + known: boolean +}): JSX.Element { + const noun = (KIND_NOUN[kind] ?? KIND_NOUN.other)(count) + return ( +
+ + + {known && count > 0 ? ( + <> + Applies to the {noun} selected. + + ) : ( + <>Select files to choose what these settings apply to. + )} + +
+ ) +} + export function OptionsPanel({ - operation, - operations, - onPickOperation, + tool, + label, options, activeKind, + activeGroup, runKind, fallbackKind, videoOutputs, @@ -1647,12 +1698,14 @@ export function OptionsPanel({ onSet, onRun }: { - /** The operation this workspace performs, plus its siblings for the switcher. */ - operation: Operation - operations: Operation[] - onPickOperation: (id: string) => void + /** The engine tool this workspace runs. */ + tool: ToolId + /** The verb's own name, used by the Run button. */ + label: string options: JobOptions activeKind: FileKind | null + /** The selected convert group, or null with nothing selected. */ + activeGroup: string | null runKind: FileKind | null /** The kind to show options for when nothing is selected: the category's own. */ fallbackKind: FileKind @@ -1683,10 +1736,12 @@ export function OptionsPanel({ } as CSSProperties } > - {/* The one coloured control: the primary choice. No "Options" header below - it: the option groups (Target format, Quality) are their own headers. */} + {/* No operation switcher: the rail IS the operation now, so a coloured + pill here would name the same thing twice. What the panel owes the user + instead is WHICH files its settings apply to, because one tab's queue + can hold several convert groups at once. */}
- +
@@ -1695,7 +1750,7 @@ export function OptionsPanel({ Falls back to the category's kind when there's no selection to read one from. Only the run button reflects whether files are ready. */} <> - {operation.tool === 'convert' && ( + {tool === 'convert' && ( )} - {operation.tool === 'compress' && ( + {tool === 'compress' && ( )} - {operation.tool === 'resize' && ( + {tool === 'resize' && ( )} - {operation.tool === 'upscale' && ( + {tool === 'upscale' && ( )} - {operation.tool === 'removebg' && } - {operation.tool === 'pdf' && ( - - )} - {operation.tool === 'archive' && ( - - )} - {operation.tool === 'generate' && } + {tool === 'removebg' && } + {tool === 'pdf' && } + {tool === 'archive' && } + {tool === 'generate' && }
@@ -1735,8 +1786,8 @@ export function OptionsPanel({ disabled={runCount === 0} className="w-full rounded-[13px] bg-accent py-3.5 text-[15px] font-semibold text-white shadow-[0_8px_20px_rgba(0,0,0,.20)] transition hover:bg-accent-hi disabled:cursor-not-allowed disabled:opacity-45 disabled:shadow-none" > - {operation.label} - {operation.tool !== 'generate' && runCount > 0 + {label} + {tool !== 'generate' && runCount > 0 ? ` ${runCount} file${runCount === 1 ? '' : 's'}` : ''} diff --git a/src/renderer/src/components/Queue.tsx b/src/renderer/src/components/Queue.tsx index cc5362c..55feab1 100644 --- a/src/renderer/src/components/Queue.tsx +++ b/src/renderer/src/components/Queue.tsx @@ -1,6 +1,7 @@ import type { JSX, MouseEvent } from 'react' import type { JobOptions, ToolId } from '@shared/types' import { formatBytes, formatEta, groupOf, inInput, inOutput, type QueueItem } from '../state' +import { groupItemsByGroup } from './queueGroups' import { baseName } from '@shared/fileKind' import { Icon } from './Icon' @@ -293,6 +294,17 @@ function OutputCard({ ) } +/** A hairline header naming one convert group inside a mixed queue. */ +function GroupHead({ label, count }: { label: string; count: string }): JSX.Element { + return ( +
+ {label} + {count} + +
+ ) +} + export function Queues({ items, tool, @@ -340,20 +352,32 @@ export function Queues({ return (
- {inputs.map((i) => ( - onItemClick(i.id, e)} - onOpen={() => onOpen('input', i)} - onMenu={(x, y) => onMenu('input', i, x, y)} - onCancel={() => onCancel(i.id)} - /> - ))} + {(() => { + const card = (i: QueueItem): JSX.Element => ( + onItemClick(i.id, e)} + onOpen={() => onOpen('input', i)} + onMenu={(x, y) => onMenu('input', i, x, y)} + onCancel={() => onCancel(i.id)} + /> + ) + // Headers only when the queue actually holds more than one group, so + // the everyday single-kind batch looks exactly as it always has. + const groups = groupItemsByGroup(inputs) + if (!groups) return inputs.map(card) + return groups.map((g) => ( +
+ + {g.items.map(card)} +
+ )) + })()}
{done.map((i) => ( diff --git a/src/renderer/src/components/CategoryRail.tsx b/src/renderer/src/components/TabRail.tsx similarity index 88% rename from src/renderer/src/components/CategoryRail.tsx rename to src/renderer/src/components/TabRail.tsx index b126d40..b89c119 100644 --- a/src/renderer/src/components/CategoryRail.tsx +++ b/src/renderer/src/components/TabRail.tsx @@ -1,29 +1,29 @@ import { useEffect, useRef, useState, type JSX } from 'react' -import { CATEGORIES, type CategoryId } from '@shared/catalog' +import { TABS, type TabId } from '@shared/tabs' import { Icon, type IconName } from './Icon' -const ALL_IDS = CATEGORIES.map((c) => c.id) -const byId = (id: CategoryId): (typeof CATEGORIES)[number] => - CATEGORIES.find((c) => c.id === id) ?? CATEGORIES[0] +const ALL_IDS = TABS.map((c) => c.id) +const byId = (id: TabId): (typeof TABS)[number] => TABS.find((c) => c.id === id) ?? TABS[0] -// The rail's layout is a user preference (order + which types are hidden), so it +// The rail's layout is a user preference (order + which verbs are hidden), so it // lives in localStorage rather than app state: it outlives a session and never -// needs to reach the engine. -const ORDER_KEY = 'filesmith.rail.order' -const HIDDEN_KEY = 'filesmith.rail.hidden' +// needs to reach the engine. The keys are tab-specific: a stored CATEGORY order +// from the old rail must not half-apply to verbs. +const ORDER_KEY = 'filesmith.rail.tabOrder' +const HIDDEN_KEY = 'filesmith.rail.tabHidden' -function loadOrder(): CategoryId[] { +function loadOrder(): TabId[] { try { - const saved = JSON.parse(localStorage.getItem(ORDER_KEY) ?? '[]') as CategoryId[] + const saved = JSON.parse(localStorage.getItem(ORDER_KEY) ?? '[]') as TabId[] const kept = saved.filter((id) => ALL_IDS.includes(id)) return [...kept, ...ALL_IDS.filter((id) => !kept.includes(id))] } catch { return ALL_IDS } } -function loadHidden(): Set { +function loadHidden(): Set { try { - return new Set((JSON.parse(localStorage.getItem(HIDDEN_KEY) ?? '[]') as CategoryId[]) ?? []) + return new Set((JSON.parse(localStorage.getItem(HIDDEN_KEY) ?? '[]') as TabId[]) ?? []) } catch { return new Set() } @@ -32,8 +32,9 @@ function loadHidden(): Set { const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, n)) /** - * The file-type rail, with an iOS-style edit mode: a pencil reveals a drag grip - * and a visibility checkmark on each row. + * The verb rail, with an iOS-style edit mode: a pencil reveals a drag grip and a + * visibility checkmark on each row. The rail names what you want DONE; the file + * kind is a property of what you dropped, not a place you navigate to. * * The reorder is a hand-rolled pointer sortable rather than native HTML5 drag, * which is janky (no live gap, a ghost image, no control over motion). Here the @@ -41,17 +42,17 @@ const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.m * way with a transition, and the new order commits on release. The maths keys * off the measured row pitch so it stays correct regardless of spacing. */ -export function CategoryRail({ - category, +export function TabRail({ + tab, counts, onSelect }: { - category: CategoryId + tab: TabId counts: Record - onSelect: (c: CategoryId) => void + onSelect: (c: TabId) => void }): JSX.Element { - const [order, setOrder] = useState(loadOrder) - const [hidden, setHidden] = useState>(loadHidden) + const [order, setOrder] = useState(loadOrder) + const [hidden, setHidden] = useState>(loadHidden) const [editing, setEditing] = useState(false) // Live drag state. `pitch` is the row-to-row distance in px, measured on grab. @@ -69,7 +70,7 @@ export function CategoryRail({ const rows = editing ? order : order.filter((id) => !hidden.has(id)) - function toggleHidden(id: CategoryId): void { + function toggleHidden(id: TabId): void { setHidden((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) @@ -80,7 +81,7 @@ export function CategoryRail({ function stopEditing(): void { setEditing(false) - if (hidden.has(category)) { + if (hidden.has(tab)) { const firstVisible = order.find((id) => !hidden.has(id)) if (firstVisible) onSelect(firstVisible) } @@ -146,7 +147,7 @@ export function CategoryRail({
+ ) +} diff --git a/src/renderer/src/components/queueGroups.ts b/src/renderer/src/components/queueGroups.ts new file mode 100644 index 0000000..be853ab --- /dev/null +++ b/src/renderer/src/components/queueGroups.ts @@ -0,0 +1,65 @@ +import { groupOf, type QueueItem } from '../state' + +// Queue grouping lives apart from Queue.tsx so that file exports only +// components (React Fast Refresh requires that), matching how the status hooks +// are split out. + +/** Display order for the group headers: the everyday kinds first, then the + * document family, then the long tail. */ +const GROUP_ORDER = ['image', 'video', 'audio', 'doc', 'sheet', 'slide', 'archive'] +const GROUP_NOUN: Record string> = { + image: (n) => `${n} image${n === 1 ? '' : 's'}`, + video: (n) => `${n} video${n === 1 ? '' : 's'}`, + audio: (n) => `${n} audio file${n === 1 ? '' : 's'}`, + doc: (n) => `${n} document${n === 1 ? '' : 's'}`, + sheet: (n) => `${n} spreadsheet${n === 1 ? '' : 's'}`, + slide: (n) => `${n} slide deck${n === 1 ? '' : 's'}`, + archive: (n) => `${n} archive${n === 1 ? '' : 's'}` +} +const GROUP_LABEL: Record = { + image: 'Images', + video: 'Video', + audio: 'Audio', + doc: 'Documents', + sheet: 'Spreadsheets', + slide: 'Slides', + archive: 'Archives' +} + +export interface ItemGroup { + group: string + label: string + count: string + items: QueueItem[] +} + +/** + * Split a queue into convert groups for the Input column's headers. Returns + * null for zero or one group, so the ordinary single-kind queue renders exactly + * as it always has and the headers only appear when they are actually telling + * the user something. + */ +export function groupItemsByGroup(items: QueueItem[]): ItemGroup[] | null { + const byGroup = new Map() + for (const i of items) { + const g = groupOf(i.file) + const cur = byGroup.get(g) + if (cur) cur.push(i) + else byGroup.set(g, [i]) + } + if (byGroup.size < 2) return null + const order = [...byGroup.keys()].sort((a, b) => { + const ia = GROUP_ORDER.indexOf(a) + const ib = GROUP_ORDER.indexOf(b) + return (ia < 0 ? GROUP_ORDER.length : ia) - (ib < 0 ? GROUP_ORDER.length : ib) + }) + return order.map((g) => { + const list = byGroup.get(g)! + return { + group: g, + label: GROUP_LABEL[g] ?? 'Other', + count: (GROUP_NOUN[g] ?? ((n: number) => `${n} file${n === 1 ? '' : 's'}`))(list.length), + items: list + } + }) +} diff --git a/src/shared/catalog.ts b/src/shared/catalog.ts deleted file mode 100644 index 4e03a1d..0000000 --- a/src/shared/catalog.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { FileKind, ToolId } from './types' - -// The app's navigation model: pick a FILE TYPE, then pick an OPERATION, then -// work in a screen that does exactly that one operation on exactly that one file -// type. Categories are what a user has; operations are what they want done. -// -// An operation is not the same thing as a ToolId. The PDF tool carries six -// distinct verbs behind an `op` option (merge, burst, …) and every one of them -// deserves its own card, while "Compress" maps to the compress tool under four -// different categories. This table is the mapping between the two. - -export type CategoryId = 'images' | 'video' | 'audio' | 'pdf' | 'documents' | 'archives' - -export interface Category { - id: CategoryId - label: string - /** The file kinds this category accepts. */ - kinds: FileKind[] - color: string - icon: string -} - -export const CATEGORIES: Category[] = [ - { id: 'images', label: 'Images', kinds: ['image'], color: '#5b5bd6', icon: 'image' }, - { id: 'video', label: 'Video', kinds: ['video'], color: '#e0483d', icon: 'video' }, - { id: 'audio', label: 'Audio', kinds: ['audio'], color: '#f5920b', icon: 'audio' }, - { id: 'pdf', label: 'PDF', kinds: ['pdf'], color: '#ef4444', icon: 'pdf' }, - { - id: 'documents', - label: 'Documents', - kinds: ['document', 'text'], - color: '#12b3a6', - icon: 'doc' - }, - { id: 'archives', label: 'Archives', kinds: ['archive'], color: '#a16207', icon: 'archive' } -] - -export interface Operation { - /** Unique within its category. */ - id: string - label: string - /** One line, shown under the label on the operation card. */ - desc: string - color: string - icon: string - /** The engine tool that performs it. */ - tool: ToolId - /** For the PDF tool, the `op` option this card stands for. */ - opKey?: string -} - -const CONVERT = (desc: string): Operation => ({ - id: 'convert', - label: 'Convert', - desc, - color: '#5b5bd6', - icon: 'convert', - tool: 'convert' -}) -const COMPRESS = (desc: string): Operation => ({ - id: 'compress', - label: 'Compress', - desc, - color: '#f5920b', - icon: 'compress', - tool: 'compress' -}) - -export const OPERATIONS: Record = { - images: [ - CONVERT('Change format (PNG, WebP, AVIF)'), - COMPRESS('Shrink file size'), - { - id: 'resize', - label: 'Resize', - desc: 'Scale by percent or exact size', - color: '#22b364', - icon: 'resize', - tool: 'resize' - }, - { - id: 'removebg', - label: 'Remove Background', - desc: 'Cut the subject out with AI', - color: '#12b3a6', - icon: 'removebg', - tool: 'removebg' - }, - { - id: 'upscale', - label: 'Upscale', - desc: 'Enlarge 2x to 4x with AI', - color: '#8b5cf6', - icon: 'upscale', - tool: 'upscale' - }, - { - id: 'generate', - label: 'Generate', - desc: 'Create an image from a text prompt', - color: '#d6409f', - icon: 'image', - tool: 'generate' - } - ], - video: [CONVERT('Change container or codec'), COMPRESS('Shrink file size')], - audio: [CONVERT('Change format (MP3, AAC, Opus)'), COMPRESS('Shrink file size')], - pdf: [ - { - id: 'extract-text', - label: 'Extract text', - desc: 'Save the text layer as .txt', - color: '#ef4444', - icon: 'text', - tool: 'pdf', - opKey: 'extract-text' - }, - { - id: 'pages-to-images', - label: 'Pages to PNG', - desc: 'Render each page to an image', - color: '#5b5bd6', - icon: 'image', - tool: 'pdf', - opKey: 'pages-to-images' - }, - { - id: 'merge', - label: 'Merge', - desc: 'Combine PDFs into one', - color: '#12b3a6', - icon: 'convert', - tool: 'pdf', - opKey: 'merge' - }, - { - id: 'split-range', - label: 'Split', - desc: 'Keep only the pages you list', - color: '#22b364', - icon: 'resize', - tool: 'pdf', - opKey: 'split-range' - }, - { - id: 'split-pages', - label: 'Burst', - desc: 'Save every page separately', - color: '#8b5cf6', - icon: 'upscale', - tool: 'pdf', - opKey: 'split-pages' - }, - { - id: 'extract-images', - label: 'Extract images', - desc: 'Pull out embedded images', - color: '#e0483d', - icon: 'removebg', - tool: 'pdf', - opKey: 'extract-images' - }, - { - id: 'to-cbz', - label: 'To CBZ', - desc: 'Pack the pages as a comic archive', - color: '#a16207', - icon: 'archive', - tool: 'archive', - opKey: 'from-pdf' - }, - COMPRESS('Shrink file size') - ], - documents: [CONVERT('Convert to PDF, DOCX, TXT')], - archives: [ - { - id: 'convert', - label: 'Convert', - desc: 'Repack as CBZ, CBR, CB7 or ZIP', - color: '#5b5bd6', - icon: 'convert', - tool: 'archive', - opKey: 'repack' - }, - { - id: 'extract', - label: 'Extract', - desc: 'Unpack into a folder', - color: '#22b364', - icon: 'resize', - tool: 'archive', - opKey: 'extract' - }, - { - id: 'to-pdf', - label: 'To PDF', - desc: 'Turn a comic archive into a PDF', - color: '#ef4444', - icon: 'pdf', - tool: 'archive', - opKey: 'to-pdf' - } - ] -} - -/** Every workspace is one (category, operation) pair, and that pair is its key. */ -export type WorkspaceKey = string -export const workspaceKey = (c: CategoryId, o: string): WorkspaceKey => `${c}:${o}` - -export function categoryOf(id: CategoryId): Category { - return CATEGORIES.find((c) => c.id === id) ?? CATEGORIES[0] -} - -export function operationsFor(id: CategoryId): Operation[] { - return OPERATIONS[id] ?? [] -} - -export function findOperation(c: CategoryId, opId: string): Operation | undefined { - return operationsFor(c).find((o) => o.id === opId) -} - -/** The operation a category opens on. There is no in-between screen, so every - * category needs a sensible landing operation: the first one listed. */ -export function defaultOperation(c: CategoryId): string { - return operationsFor(c)[0]?.id ?? '' -} - -/** True when a file belongs in this category (drives the type-locked drop zone). */ -export function acceptsKind(c: CategoryId, kind: FileKind): boolean { - return categoryOf(c).kinds.includes(kind) -} diff --git a/test/archive-catalog-nav.test.ts b/test/archive-catalog-nav.test.ts deleted file mode 100644 index 0f70173..0000000 --- a/test/archive-catalog-nav.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { acceptsKind, defaultOperation, findOperation, operationsFor } from '@shared/catalog' -import { defaultOptionsFor } from '../src/renderer/src/state' - -describe('archives category', () => { - it('accepts archive files and nothing else', () => { - expect(acceptsKind('archives', 'archive')).toBe(true) - expect(acceptsKind('archives', 'image')).toBe(false) - }) - - it('lands on Convert', () => { - expect(defaultOperation('archives')).toBe('convert') - }) - - it('offers convert, extract and to-pdf', () => { - expect(operationsFor('archives').map((o) => o.id)).toEqual(['convert', 'extract', 'to-pdf']) - }) - - it('routes every archives operation to the archive tool', () => { - for (const o of operationsFor('archives')) expect(o.tool).toBe('archive') - }) - - it('adds a To CBZ card under PDF that runs the archive tool', () => { - const card = findOperation('pdf', 'to-cbz') - expect(card?.tool).toBe('archive') - expect(card?.opKey).toBe('from-pdf') - }) - - it('seeds each archive workspace with its own op', () => { - expect(defaultOptionsFor('archives', 'extract').op).toBe('extract') - expect(defaultOptionsFor('archives', 'to-pdf').op).toBe('to-pdf') - expect(defaultOptionsFor('pdf', 'to-cbz').op).toBe('from-pdf') - }) -}) diff --git a/test/catalog-tools.test.ts b/test/catalog-tools.test.ts deleted file mode 100644 index fd68125..0000000 --- a/test/catalog-tools.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { OPERATIONS } from '../src/shared/catalog' -import { getTool } from '../src/main/tools/registry' - -// TOOLS in tools/registry.ts is a Partial>, which -// removes the compiler's exhaustiveness check: a renamed or added ToolId -// compiles fine and fails at runtime with "Unknown tool: X". This walks the -// UI's own catalog and asserts every operation resolves to an engine module. -// -// 'generate' is the one deliberate exemption: generation runs on its own -// generate:* IPC path (see App.tsx's early return), not through the job queue. -const QUEUE_EXEMPT = new Set(['generate']) - -describe('catalog <-> engine registry', () => { - it('every catalog operation maps to an implemented tool module', () => { - for (const ops of Object.values(OPERATIONS)) { - for (const op of ops) { - if (QUEUE_EXEMPT.has(op.tool)) continue - expect(getTool(op.tool), `tool '${op.tool}' (operation '${op.id}')`).toBeDefined() - } - } - }) -}) diff --git a/test/queue-groups.test.ts b/test/queue-groups.test.ts new file mode 100644 index 0000000..dc1ba3c --- /dev/null +++ b/test/queue-groups.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { groupItemsByGroup } from '../src/renderer/src/components/queueGroups' +import type { QueueItem } from '../src/renderer/src/state' +import type { FileKind } from '@shared/types' + +const q = (id: string, kind: FileKind, ext = '.x'): QueueItem => ({ + id, + file: { path: id, name: id, ext, kind, size: 1 }, + thumb: null, + status: 'ready', + percent: 0 +}) + +describe('groupItemsByGroup', () => { + it('returns no groups for a single-group queue, so it renders as today', () => { + expect(groupItemsByGroup([q('a', 'image'), q('b', 'image')])).toBeNull() + }) + + it('returns no groups for an empty queue', () => { + expect(groupItemsByGroup([])).toBeNull() + }) + + it('treats a pdf, a docx and a txt as ONE group, since they batch together', () => { + const items = [q('a', 'pdf', '.pdf'), q('b', 'document', '.docx'), q('c', 'text', '.txt')] + expect(groupItemsByGroup(items)).toBeNull() + }) + + it('groups a mixed queue in a stable order', () => { + const g = groupItemsByGroup([q('v', 'video'), q('a', 'image'), q('s', 'audio')])! + expect(g.map((x) => x.group)).toEqual(['image', 'video', 'audio']) + expect(g[0].items.map((i) => i.id)).toEqual(['a']) + }) + + it('labels each group with its own plural', () => { + const g = groupItemsByGroup([q('a', 'image'), q('v', 'video'), q('w', 'video')])! + expect(g.map((x) => x.label)).toEqual(['Images', 'Video']) + expect(g.map((x) => x.count)).toEqual(['1 image', '2 videos']) + }) + + it('separates archives from documents', () => { + const g = groupItemsByGroup([q('d', 'document', '.docx'), q('c', 'archive', '.cbz')])! + expect(g.map((x) => x.group)).toEqual(['doc', 'archive']) + expect(g[1].count).toBe('1 archive') + }) + + it('puts an unknown group last rather than dropping it', () => { + const g = groupItemsByGroup([q('o', 'other', '.xyz'), q('a', 'image')])! + expect(g[0].group).toBe('image') + expect(g[1].items.map((i) => i.id)).toEqual(['o']) + }) +}) diff --git a/test/queues.test.ts b/test/queues.test.ts index c05af50..e082391 100644 --- a/test/queues.test.ts +++ b/test/queues.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' -import { reducer, initialState, defaultOptionsFor, type AppState } from '../src/renderer/src/state' -import { workspaceKey } from '../src/shared/catalog' +import { reducer, initialState, defaultOptionsFor, optionsKey } from '../src/renderer/src/state' import type { FileInfo } from '../src/shared/types' const img = (name: string): FileInfo => ({ @@ -10,46 +9,56 @@ const img = (name: string): FileInfo => ({ kind: 'image', size: 10 }) +const pdf = (name: string): FileInfo => ({ + path: `C:/x/${name}`, + name, + ext: '.pdf', + kind: 'pdf', + size: 10 +}) -const IMAGES = 'images' as const -const PDF = 'pdf' as const - -/** Switch operation within the current category (the queue is shared). */ -const open = (s: AppState, operation: string): AppState => - reducer(s, { type: 'setOperation', operation }) +const CONVERT = 'convert' +const COMPRESS = 'compress' -const start = open(initialState, 'convert') +const start = initialState -describe('per-category queues (shared across operations)', () => { - it('keeps files when switching operation within a category', () => { - // The whole point: an image added while converting is still there after - // switching to compress, because the queue belongs to the file type. - let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) - expect(s.queues[IMAGES]!.items).toHaveLength(1) - s = open(s, 'compress') - expect(s.queues[IMAGES]!.items).toHaveLength(1) // retained - s = open(s, 'upscale') - expect(s.queues[IMAGES]!.items).toHaveLength(1) // still retained +describe('per-verb queues', () => { + it('keeps each verb queue separate: the queue belongs to what you are doing', () => { + let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT }) + s = reducer(s, { type: 'setTab', tab: 'compress' }) + s = reducer(s, { type: 'addItems', files: [img('b.png')], key: COMPRESS }) + expect(s.queues[COMPRESS]!.items).toHaveLength(1) + expect(s.queues[CONVERT]!.items).toHaveLength(1) // convert untouched }) - it('keeps each file type queue separate', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) - s = reducer(s, { type: 'setCategory', category: 'pdf' }) - s = reducer(s, { + it('holds several convert groups in one queue at once', () => { + // The whole point of the verb-first model: a Convert queue can carry images + // and PDFs together, and the options panel scopes to whichever is selected. + const s = reducer(start, { type: 'addItems', - files: [{ path: 'C:/x/d.pdf', name: 'd.pdf', ext: '.pdf', kind: 'pdf', size: 5 }], - category: 'pdf' + files: [img('a.png'), pdf('d.pdf')], + key: CONVERT }) - expect(s.queues[PDF]!.items).toHaveLength(1) - expect(s.queues[IMAGES]!.items).toHaveLength(1) // images untouched + expect(s.queues[CONVERT]!.items).toHaveLength(2) + // Only the first group is auto-selected, so a batch is never cross-group. + expect(s.queues[CONVERT]!.selected).toHaveLength(1) }) - it('routes a job event to whichever category queue holds the item', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) - const id = s.queues[IMAGES]!.items[0].id - s = open(s, 'resize') + it('gives each Tools card its own workspace queue', () => { + let s = reducer(start, { type: 'setTab', tab: 'tools' }) + s = reducer(s, { type: 'setActiveTool', tool: 'pdf-merge' }) + s = reducer(s, { type: 'addItems', files: [pdf('a.pdf')], key: 'tools:pdf-merge' }) + s = reducer(s, { type: 'setActiveTool', tool: 'pdf-burst' }) + expect(s.queues['tools:pdf-merge']!.items).toHaveLength(1) + expect(s.queues['tools:pdf-burst']!.items).toHaveLength(0) + }) + + it('routes a job event to whichever queue holds the item', () => { + let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT }) + const id = s.queues[CONVERT]!.items[0].id + s = reducer(s, { type: 'setTab', tab: 'resize' }) s = reducer(s, { type: 'jobEvent', event: { id, status: 'done', outputPath: 'C:/x/a.webp' } }) - const items = s.queues[IMAGES]!.items + const items = s.queues[CONVERT]!.items expect(items).toHaveLength(2) expect(items[0].id).toBe(id) expect(items[0].status).toBe('done') @@ -59,8 +68,8 @@ describe('per-category queues (shared across operations)', () => { }) it('appends a fresh result on each finished run; the source persists', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) - const id = s.queues[IMAGES]!.items[0].id + let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT }) + const id = s.queues[CONVERT]!.items[0].id s = reducer(s, { type: 'jobEvent', event: { id, status: 'done', outputPath: 'C:/x/a (1).webp' } @@ -69,7 +78,7 @@ describe('per-category queues (shared across operations)', () => { type: 'jobEvent', event: { id, status: 'done', outputPath: 'C:/x/a (2).webp' } }) - const items = s.queues[IMAGES]!.items + const items = s.queues[CONVERT]!.items expect(items.filter((i) => !i.isResult)).toHaveLength(1) const results = items.filter((i) => i.isResult) expect(results).toHaveLength(2) @@ -77,7 +86,7 @@ describe('per-category queues (shared across operations)', () => { }) it('addSources appends pre-built input rows and selects them', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) + let s = reducer(start, { type: 'addItems', files: [img('a.png')], key: CONVERT }) const clone = { id: 'clone-1', file: img('a.png'), @@ -85,53 +94,54 @@ describe('per-category queues (shared across operations)', () => { status: 'ready' as const, percent: 0 } - s = reducer(s, { type: 'addSources', items: [clone], category: 'images' }) - expect(s.queues[IMAGES]!.items.filter((i) => !i.isResult)).toHaveLength(2) - expect(s.queues[IMAGES]!.selected).toEqual(['clone-1']) + s = reducer(s, { type: 'addSources', items: [clone], key: CONVERT }) + expect(s.queues[CONVERT]!.items.filter((i) => !i.isResult)).toHaveLength(2) + expect(s.queues[CONVERT]!.selected).toEqual(['clone-1']) }) }) describe('navigation', () => { - it('opens directly on the first operation, with no chooser in between', () => { - expect(initialState.category).toBe('images') - expect(initialState.operation).toBe('convert') + it('opens on the first verb, with no chooser in between', () => { + expect(initialState.tab).toBe('convert') + expect(initialState.activeTool).toBeNull() }) - it("switching file type lands on that type's default operation, ready for files", () => { - // Operation ids are per-category, so carrying one across would be meaningless. - let s = open(initialState, 'upscale') - s = reducer(s, { type: 'setCategory', category: 'pdf' }) - expect(s.category).toBe('pdf') - expect(s.operation).toBe('extract-text') - s = reducer(s, { - type: 'addItems', - files: [{ path: 'C:/x/a.pdf', name: 'a.pdf', ext: '.pdf', kind: 'pdf', size: 10 }], - category: 'pdf' - }) - expect(s.queues[PDF]!.items).toHaveLength(1) + it('returns to the Tools card you last had open', () => { + let s = reducer(initialState, { type: 'setTab', tab: 'tools' }) + s = reducer(s, { type: 'setActiveTool', tool: 'pdf-burst' }) + s = reducer(s, { type: 'setTab', tab: 'convert' }) + expect(s.activeTool).toBeNull() + s = reducer(s, { type: 'setTab', tab: 'tools' }) + expect(s.activeTool).toBe('pdf-burst') }) - it('keeps options per operation while the queue is shared', () => { - // Set a Convert-only option, switch away and back: the option is remembered, - // and separate from Compress's options. - let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) - s = reducer(s, { type: 'setOption', key: 'format', value: '.avif' }) - s = open(s, 'compress') - expect(s.options[workspaceKey('images', 'compress')].quality).toBe(80) // its own default - s = open(s, 'convert') - expect(s.options[workspaceKey('images', 'convert')].format).toBe('.avif') // remembered + it('keeps options per convert group inside one verb', () => { + // Set an image target, then a video target: one Convert tab holds both, and + // neither overwrites the other. + let s = reducer(initialState, { + type: 'setOption', + group: 'image', + key: 'format', + value: '.avif' + }) + s = reducer(s, { type: 'setOption', group: 'video', key: 'format', value: '.mkv' }) + expect(s.options[optionsKey('convert', null, 'image')].format).toBe('.avif') + expect(s.options[optionsKey('convert', null, 'video')].format).toBe('.mkv') }) - it('seeds a new operation with its tool defaults', () => { - const s = open(initialState, 'compress') - expect(s.options[workspaceKey('images', 'compress')].quality).toBe(80) + it('seeds a group with its engine tool defaults', () => { + const s = reducer(initialState, { type: 'setTab', tab: 'compress' }) + const s2 = reducer(s, { type: 'setOption', group: 'image', key: 'scale', value: 50 }) + expect(s2.options[optionsKey('compress', null, 'image')].quality).toBe(80) }) - it('carries the PDF verb into the options, since one tool serves several ops', () => { + it('carries a tool card verb into its options, since one tool serves several', () => { // "Burst" and "Merge" are both the pdf tool; the op key is what separates them. - expect(defaultOptionsFor('pdf', 'split-pages').op).toBe('split-pages') - expect(defaultOptionsFor('pdf', 'merge').op).toBe('merge') - // Compress under PDF is the compress tool, so it carries no verb. - expect(defaultOptionsFor('pdf', 'compress').op).toBeUndefined() + expect(defaultOptionsFor('tools', 'pdf-burst', 'doc').op).toBe('split-pages') + expect(defaultOptionsFor('tools', 'pdf-merge', 'doc').op).toBe('merge') + // Converting an image is the plain convert tool, so it carries no verb. + expect(defaultOptionsFor('convert', null, 'image').op).toBeUndefined() + // Converting an ARCHIVE is the archive tool with op 'repack', on the same tab. + expect(defaultOptionsFor('convert', null, 'archive').op).toBe('repack') }) }) diff --git a/test/session-persist.test.ts b/test/session-persist.test.ts index a933588..7278964 100644 --- a/test/session-persist.test.ts +++ b/test/session-persist.test.ts @@ -40,7 +40,7 @@ function stateWith(items: QueueItem[]): AppState { return { ...initialState, queues: { - images: { + convert: { items, selected: [items[0]?.id].filter(Boolean) as string[], anchor: items[0]?.id ?? null @@ -61,7 +61,7 @@ describe('session persistence round-trip', () => { const snap = sessionSnapshot(state, ['C:/gen/one.png']) const parsed = parseSession(snap) expect(parsed).not.toBeNull() - const items = parsed!.state.queues.images!.items + const items = parsed!.state.queues.convert!.items // Thumb dropped (reloadable), running settled back to ready. expect(items[0].thumb).toBeNull() expect(items[0].status).toBe('ready') @@ -72,12 +72,18 @@ describe('session persistence round-trip', () => { expect(parsed!.genResults).toEqual(['C:/gen/one.png']) }) - it('rejects a session with a mismatched version', () => { - const snap = sessionSnapshot(initialState, []) as Record - expect(parseSession({ ...snap, version: 999 })).toBeNull() + it('rejects junk, but never a merely older session', () => { expect(parseSession(null)).toBeNull() expect(parseSession('garbage')).toBeNull() - expect(parseSession({ version: 1 })).not.toBeNull() // tolerant of missing optional fields + // A v1 blob is MIGRATED, not discarded: its files are the user's in-flight + // work, and losing them to an upgrade is worse than resetting settings. + expect(parseSession({ version: 1 })).not.toBeNull() + // A version from the future (a downgrade) takes the same path and yields an + // empty but usable session rather than crashing the window. + const snap = sessionSnapshot(initialState, []) as Record + const future = parseSession({ ...snap, version: 999 }) + expect(future).not.toBeNull() + expect(future!.state.tab).toBe('convert') }) it('collects every on-disk path referenced by the session', () => { @@ -98,40 +104,30 @@ describe('session persistence round-trip', () => { ]) const exists = new Set(['C:/in/present.png', 'C:/out/present.png', 'C:/gen/keep.png']) const pruned = pruneMissing(state, ['C:/gen/keep.png', 'C:/gen/gone.png'], exists) - const ids = pruned.state.queues.images!.items.map((i) => i.id) + const ids = pruned.state.queues.convert!.items.map((i) => i.id) expect(ids).toEqual(['a', 'c']) // b (source gone) and d (output gone) dropped expect(pruned.genResults).toEqual(['C:/gen/keep.png']) // Selection/anchor are cleared on prune so they can't dangle. - expect(pruned.state.queues.images!.selected).toEqual([]) + expect(pruned.state.queues.convert!.selected).toEqual([]) }) - it('always launches into the first category, whatever was open at close', () => { - // The category itself is not restored - only each category's sub-page is. - const state: AppState = { ...initialState, category: 'video', operation: 'compress' } + it('always launches into the first verb, whatever was open at close', () => { + const state: AppState = { ...initialState, tab: 'compress' } const parsed = parseSession(sessionSnapshot(state, [])) expect(parsed).not.toBeNull() - expect(parsed!.state.category).toBe('images') + expect(parsed!.state.tab).toBe('convert') }) - it("remembers each category's last sub-page, including the one open at close", () => { - const state: AppState = { - ...initialState, - category: 'video', - operation: 'compress', - lastOperation: { images: 'generate', video: 'compress' } - } + it('remembers the Tools card that was last open', () => { + const state: AppState = { ...initialState, tab: 'tools', lastTool: 'pdf-burst' } const parsed = parseSession(sessionSnapshot(state, [])) - // Launches into Images ON its remembered sub-page... - expect(parsed!.state.category).toBe('images') - expect(parsed!.state.operation).toBe('generate') - // ...and Video's sub-page survives for when the user switches to it. - expect(parsed!.state.lastOperation.video).toBe('compress') + expect(parsed!.state.tab).toBe('convert') + expect(parsed!.state.lastTool).toBe('pdf-burst') }) - it('falls back to the default operation when the remembered one is gone', () => { - const state: AppState = { ...initialState, lastOperation: { images: 'renamed-away' } } + it('forgets a Tools card that no longer exists', () => { + const state: AppState = { ...initialState, lastTool: 'renamed-away' } const parsed = parseSession(sessionSnapshot(state, [])) - expect(parsed!.state.category).toBe('images') - expect(parsed!.state.operation).toBe('convert') + expect(parsed!.state.lastTool).toBeNull() }) }) diff --git a/test/tabs-registry.test.ts b/test/tabs-registry.test.ts new file mode 100644 index 0000000..5ea3d00 --- /dev/null +++ b/test/tabs-registry.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { TABS, TOOL_CARDS, engineFor } from '@shared/tabs' +import { getTool } from '../src/main/tools/registry' + +// TOOLS in tools/registry.ts is a Partial>, which +// removes the compiler's exhaustiveness check: a renamed or added ToolId +// compiles fine and fails at runtime with "Unknown tool: X". This walks the +// UI's own navigation model and asserts every route resolves to an engine +// module. Replaces the old catalog <-> registry check. +// +// 'generate' is the one deliberate exemption: generation runs on its own +// generate:* IPC path (see App.tsx's early return), not through the job queue. +const QUEUE_EXEMPT = new Set(['generate']) + +describe('tabs <-> engine registry', () => { + it('every verb resolves to an implemented tool module, for every kind it takes', () => { + for (const tab of TABS) { + if (tab.id === 'tools') continue // the grid itself runs nothing + for (const kind of tab.kinds.length ? tab.kinds : (['image'] as const)) { + // The Convert tab routes archives to a different engine than images, so + // resolve per group rather than trusting tab.tool. + const group = kind === 'archive' ? 'archive' : kind + const { tool } = engineFor(tab.id, group) + if (QUEUE_EXEMPT.has(tool)) continue + expect(getTool(tool), `tool '${tool}' (tab '${tab.id}', kind '${kind}')`).toBeDefined() + } + } + }) + + it('every tool card resolves to an implemented tool module', () => { + for (const c of TOOL_CARDS) { + expect(getTool(c.tool), `tool '${c.tool}' (card '${c.id}')`).toBeDefined() + } + }) +}) From 3111c7d7ee22a297251bed1e6cac9651be55cb92 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:55:04 +0200 Subject: [PATCH 04/10] chore: 0.4.0, document verb-first navigation Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSq6HRwmaRKAv1t2hrdkJB --- CLAUDE.md | 8 +++++++- README.md | 4 ++-- e2e/smoke.spec.ts | 6 ++++-- e2e/workflows.spec.ts | 25 +++++++++++++++---------- package.json | 2 +- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bbe434a..7bb0466 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,11 @@ PowerShell scripts (`../RCMM/manager/src/RCMM/`), lifted into a real GUI app. ## Design process — READ THIS +Navigation is VERB-first: the rail is Convert / Compress / Resize / Upscale / +Remove BG / Generate / Tools, each owning one queue that may hold several +convert groups at once. A selection never spans two convert groups, because one +options panel can only describe one target set. See `src/shared/tabs.ts`. + **The look is designed collaboratively with the owner. Make NO visual assumptions.** Before building or restyling any UI, present mockups (self-contained browser HTML, like the RCMM Show/Hide exploration), offer options, and iterate to explicit sign-off. This covers @@ -53,7 +58,8 @@ src/ (planned) ipc.ts renderer <-> engine wiring preload/ contextBridge — the typed `window.filesmith` API renderer/ React UI (placeholder until designed) - shared/ (planned) types.ts — Job, ToolId, FileKind, Options, progress events + shared/ types.ts — Job, ToolId, FileKind, Options, progress events + tabs.ts — the VERB-first navigation model (rail tabs + Tools cards) resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by electron-builder) ``` diff --git a/README.md b/README.md index 5888af3..52de6c7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ A local file toolkit for Windows. Drop files, pick a tool, get results. ## What it is -Filesmith puts the everyday file jobs behind one clean window: convert, compress, resize, and more, across **images, video, audio, PDFs, documents, and archives**. Drop a pile of files, pick a tool, and it runs the batch with thumbnails, live per-file progress, rich previews, and collision-safe output that never overwrites your originals. +Filesmith puts the everyday file jobs behind one clean window. Pick **what you want done** - convert, compress, resize, and more - then drop files of any supported type: **images, video, audio, PDFs, documents, and archives**. Drop a pile of files, pick a tool, and it runs the batch with thumbnails, live per-file progress, rich previews, and collision-safe output that never overwrites your originals. Everything runs **locally on your machine**, and your queue and produced files are **remembered across restarts**, so you can close the app mid-batch and pick up where you left off. @@ -66,7 +66,7 @@ Download **`Filesmith-Setup-x64-.exe`** from the [latest release](https ## Use 1. Open **Filesmith** from the Start menu. -2. Pick a file type in the left rail (Images, Video, Audio, PDF, Documents, Archives). +2. Pick what you want to do in the left rail (Convert, Compress, Resize, Upscale, Remove BG, Generate, Tools). 3. Choose a tool from the top-right (Convert, Compress, Resize, …), set its options. 4. Drop files onto the drop zone (or click **browse**), then run. diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 21a6cf4..befb12b 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -71,8 +71,10 @@ function runJob(tool: string, input: string, options: Record): ) } -test('the app boots to the Images workspace', async () => { - await expect(page.locator('text=Images').first()).toBeVisible() +test('the app boots to the Convert workspace', async () => { + // The rail names the verb, so the app opens on the first one rather than on + // a file type. + await expect(page.locator('h1', { hasText: 'Convert' }).first()).toBeVisible() // The primary action is on screen without scrolling (the pinned footer). await expect(page.locator('button', { hasText: 'Convert' }).last()).toBeVisible() }) diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts index 0f667d9..d5b6a2e 100644 --- a/e2e/workflows.spec.ts +++ b/e2e/workflows.spec.ts @@ -169,24 +169,26 @@ test.afterAll(async () => { // --- UI navigation ----------------------------------------------------------- +/** Tools remembers the card you last had open (and that survives a restart via + * session.json), so reaching the grid means backing out of any open tool. */ +async function openToolsGrid(p: Page): Promise { + await p.locator('button:has-text("Tools")').first().click() + const back = p.locator('button[aria-label="Back to Tools"]') + if (await back.count()) await back.first().click() + await expect(p.locator('h1', { hasText: 'Tools' }).first()).toBeVisible() +} + test('the rail lists every verb and opens its workspace', async () => { - for (const verb of [ - 'Convert', - 'Compress', - 'Resize', - 'Upscale', - 'Remove BG', - 'Generate', - 'Tools' - ]) { + for (const verb of ['Convert', 'Compress', 'Resize', 'Upscale', 'Remove BG', 'Generate']) { await page.locator(`button:has-text("${verb}")`).first().click() await expect(page.locator('h1', { hasText: verb }).first()).toBeVisible() } + await openToolsGrid(page) await page.locator('button:has-text("Convert")').first().click() }) test('Tools groups its one-off verbs and opens one as a workspace', async () => { - await page.locator('button:has-text("Tools")').first().click() + await openToolsGrid(page) for (const t of ['Extract text', 'Merge', 'Burst', 'Archive to PDF', 'PDF to CBZ']) { await expect(page.locator(`text=${t}`).first()).toBeVisible() } @@ -194,6 +196,9 @@ test('Tools groups its one-off verbs and opens one as a workspace', async () => // A tool workspace is an ordinary queue titled with the tool's name. await expect(page.locator('h1', { hasText: 'Merge' }).first()).toBeVisible() await expect(page.locator('text=Input').first()).toBeVisible() + // Back returns to the grid, which is the app's only second level. + await page.locator('button[aria-label="Back to Tools"]').first().click() + await expect(page.locator('h1', { hasText: 'Tools' }).first()).toBeVisible() await page.locator('button:has-text("Convert")').first().click() }) diff --git a/package.json b/package.json index 3a8d8f8..476c3e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filesmith", - "version": "0.3.0", + "version": "0.4.0", "description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.", "author": "Max", "license": "MIT", From 6d5fee0c571f6a668a788cc53922c710fc8496c4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:04:47 +0200 Subject: [PATCH 05/10] fix(nav): give each Tools card its own glyph Reusing convert/resize/upscale made Split, Burst and Extract read as the same action at a glance. Unpack and pack now differ by arrow direction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSq6HRwmaRKAv1t2hrdkJB --- src/renderer/src/components/Icon.tsx | 16 ++++++++++++++++ src/shared/tabs.ts | 14 +++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/Icon.tsx b/src/renderer/src/components/Icon.tsx index d6961cd..594fc1d 100644 --- a/src/renderer/src/components/Icon.tsx +++ b/src/renderer/src/components/Icon.tsx @@ -9,6 +9,13 @@ export type IconName = | 'pdf' | 'archive' | 'tools' + | 'merge' + | 'split' + | 'burst' + | 'pull' + | 'unpack' + | 'topdf' + | 'tocbz' | 'upload' | 'check' | 'clock' @@ -62,6 +69,15 @@ const PATHS: Record = { pdf: , archive: , tools: , + // One glyph per tool card: reusing convert/resize/upscale here made Split, + // Burst and Extract read as the same action at a glance. + merge: , + split: , + burst: , + pull: , + unpack: , + topdf: , + tocbz: , upload: , check: , clock: ( diff --git a/src/shared/tabs.ts b/src/shared/tabs.ts index 86d4154..d65dc56 100644 --- a/src/shared/tabs.ts +++ b/src/shared/tabs.ts @@ -136,7 +136,7 @@ export const TOOL_CARDS: ToolCard[] = [ desc: 'Combine PDFs into one', group: 'PDF', color: '#ef4444', - icon: 'convert', + icon: 'merge', tool: 'pdf', opKey: 'merge', kinds: ['pdf'] @@ -147,7 +147,7 @@ export const TOOL_CARDS: ToolCard[] = [ desc: 'Keep only the pages you list', group: 'PDF', color: '#ef4444', - icon: 'resize', + icon: 'split', tool: 'pdf', opKey: 'split-range', kinds: ['pdf'] @@ -158,7 +158,7 @@ export const TOOL_CARDS: ToolCard[] = [ desc: 'Save every page separately', group: 'PDF', color: '#ef4444', - icon: 'upscale', + icon: 'burst', tool: 'pdf', opKey: 'split-pages', kinds: ['pdf'] @@ -169,7 +169,7 @@ export const TOOL_CARDS: ToolCard[] = [ desc: 'Pull out embedded images', group: 'PDF', color: '#ef4444', - icon: 'removebg', + icon: 'pull', tool: 'pdf', opKey: 'extract-images', kinds: ['pdf'] @@ -180,7 +180,7 @@ export const TOOL_CARDS: ToolCard[] = [ desc: 'Unpack into a folder', group: 'Archives', color: '#a16207', - icon: 'resize', + icon: 'unpack', tool: 'archive', opKey: 'extract', kinds: ['archive'] @@ -191,7 +191,7 @@ export const TOOL_CARDS: ToolCard[] = [ desc: 'Comic archive becomes a PDF', group: 'Archives', color: '#a16207', - icon: 'pdf', + icon: 'topdf', tool: 'archive', opKey: 'to-pdf', kinds: ['archive'] @@ -202,7 +202,7 @@ export const TOOL_CARDS: ToolCard[] = [ desc: 'Pack pages as a comic archive', group: 'Archives', color: '#a16207', - icon: 'archive', + icon: 'tocbz', tool: 'archive', opKey: 'from-pdf', kinds: ['pdf'] From 0c18ef54d8e77ddf083d7c821cc642b6cd1d012d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:21:32 +0200 Subject: [PATCH 06/10] fix(nav): honour the one-group rule on ctrl+click, and name groups not files Self-testing the real UI with files in it found three things: - ctrl+click on a row from another group did NOTHING. App.tsx still swallowed the click with the old guard, contradicting the reducer (which moves the selection) and the approved design. The reducer owns the rule; the guard is gone. - The scope chip named the file KIND, so a pdf plus a txt read as '2 PDFs' when it runs as one document batch. It now names the convert group. - The Run button said 'Convert 2 files', which in a mixed queue hides which two. It now says 'Convert 2 images'. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSq6HRwmaRKAv1t2hrdkJB --- src/renderer/src/App.tsx | 11 ++--- src/renderer/src/components/OptionsPanel.tsx | 47 +++++++------------- src/renderer/src/components/queueGroups.ts | 19 ++++++++ test/group-naming.test.ts | 39 ++++++++++++++++ 4 files changed, 78 insertions(+), 38 deletions(-) create mode 100644 test/group-naming.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1494ae9..d473c4e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -348,13 +348,9 @@ export default function App(): JSX.Element { function onItemClick(id: string, e: MouseEvent): void { const item = cur.items.find((i) => i.id === id) if (!item) return - const modified = e.shiftKey || e.ctrlKey || e.metaKey - if (modified) { - // Multi-select stays within one convert group (docs/text/pdf batch - // together): ignore modified clicks on files from another group. - const anchor = selectedItems.length ? effectiveFile(selectedItems[0]) : effectiveFile(item) - if (groupOf(effectiveFile(item)) !== groupOf(anchor)) return - } + // The one-group rule lives in the reducer, which MOVES the selection to a + // file from another group rather than extending into it. Swallowing the + // click here instead made ctrl+click on a dimmed row do nothing at all. const mode: SelectMode = e.shiftKey ? 'range' : e.ctrlKey || e.metaKey ? 'toggle' : 'single' dispatch({ type: 'select', id, mode }) } @@ -1028,6 +1024,7 @@ export default function App(): JSX.Element { options={curOptions} activeKind={activeKind} activeGroup={activeGroup} + optGroup={optGroup} runKind={runKind} fallbackKind={fallbackKind} videoOutputs={videoOutputs} diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx index 0e21ff9..50d48f0 100644 --- a/src/renderer/src/components/OptionsPanel.tsx +++ b/src/renderer/src/components/OptionsPanel.tsx @@ -24,6 +24,7 @@ import { GEN_SIZES, GEN_STYLES, GEN_MAX_COUNT, clampDim } from '@shared/generate import { archInfoFor, type GenArch, type GenModel } from '@shared/genArch' import { Icon } from './Icon' import type { ToolId } from '@shared/types' +import { GROUP_COLOR, groupNoun } from './queueGroups' import { PidInstallCard } from './PidUpscale' import { usePidStatus } from './usePidStatus' import { ComfyImportCard } from './ComfyImport' @@ -1629,49 +1630,30 @@ function GenerateOptions({ ) } -const KIND_COLOR: Record = { - image: '#5b5bd6', - video: '#e0483d', - audio: '#f5920b', - pdf: '#ef4444', - document: '#12b3a6', - text: '#12b3a6', - archive: '#a16207', - other: '#6e6e73' -} -const KIND_NOUN: Record string> = { - image: (n) => `${n} image${n === 1 ? '' : 's'}`, - video: (n) => `${n} video${n === 1 ? '' : 's'}`, - audio: (n) => `${n} audio file${n === 1 ? '' : 's'}`, - pdf: (n) => `${n} PDF${n === 1 ? '' : 's'}`, - document: (n) => `${n} document${n === 1 ? '' : 's'}`, - text: (n) => `${n} text file${n === 1 ? '' : 's'}`, - archive: (n) => `${n} archive${n === 1 ? '' : 's'}`, - other: (n) => `${n} file${n === 1 ? '' : 's'}` -} - /** What these settings will act on. The queue can hold images and video at the - * same time, so "Run" has to say which of them it means. */ + * same time, so Run has to say which of them it means. Named by CONVERT GROUP, + * not file kind: a pdf plus a txt is "2 documents", because they run as one + * batch under one target set. */ function ScopeChip({ - kind, + group, count, known }: { - kind: FileKind + group: string count: number known: boolean }): JSX.Element { - const noun = (KIND_NOUN[kind] ?? KIND_NOUN.other)(count) return (
{known && count > 0 ? ( <> - Applies to the {noun} selected. + Applies to the {groupNoun(group, count)}{' '} + selected. ) : ( <>Select files to choose what these settings apply to. @@ -1687,6 +1669,7 @@ export function OptionsPanel({ options, activeKind, activeGroup, + optGroup, runKind, fallbackKind, videoOutputs, @@ -1706,6 +1689,8 @@ export function OptionsPanel({ activeKind: FileKind | null /** The selected convert group, or null with nothing selected. */ activeGroup: string | null + /** The group the options actually belong to (falls back when nothing is selected). */ + optGroup: string runKind: FileKind | null /** The kind to show options for when nothing is selected: the category's own. */ fallbackKind: FileKind @@ -1741,7 +1726,7 @@ export function OptionsPanel({ instead is WHICH files its settings apply to, because one tab's queue can hold several convert groups at once. */}
- +
@@ -1787,9 +1772,9 @@ export function OptionsPanel({ className="w-full rounded-[13px] bg-accent py-3.5 text-[15px] font-semibold text-white shadow-[0_8px_20px_rgba(0,0,0,.20)] transition hover:bg-accent-hi disabled:cursor-not-allowed disabled:opacity-45 disabled:shadow-none" > {label} - {tool !== 'generate' && runCount > 0 - ? ` ${runCount} file${runCount === 1 ? '' : 's'}` - : ''} + {/* Name the GROUP, not "files": in a mixed queue "Convert 2 files" + hides which two. */} + {tool !== 'generate' && runCount > 0 ? ` ${groupNoun(optGroup, runCount)}` : ''}
diff --git a/src/renderer/src/components/queueGroups.ts b/src/renderer/src/components/queueGroups.ts index be853ab..f8a4931 100644 --- a/src/renderer/src/components/queueGroups.ts +++ b/src/renderer/src/components/queueGroups.ts @@ -26,6 +26,25 @@ const GROUP_LABEL: Record = { archive: 'Archives' } +/** The dot colour beside a group's name in the options panel. */ +export const GROUP_COLOR: Record = { + image: '#5b5bd6', + video: '#e0483d', + audio: '#f5920b', + doc: '#12b3a6', + sheet: '#12b3a6', + slide: '#12b3a6', + archive: '#a16207' +} + +/** "2 images" / "1 document" / "3 archives". Named by CONVERT GROUP, not file + * kind: a pdf plus a txt is "2 documents", because they run as one batch and + * the options panel describes them as one. */ +export function groupNoun(group: string, n: number): string { + const f = GROUP_NOUN[group] + return f ? f(n) : `${n} file${n === 1 ? '' : 's'}` +} + export interface ItemGroup { group: string label: string diff --git a/test/group-naming.test.ts b/test/group-naming.test.ts new file mode 100644 index 0000000..fde2185 --- /dev/null +++ b/test/group-naming.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { GROUP_COLOR, groupNoun } from '../src/renderer/src/components/queueGroups' + +// The options panel and the Run button both describe a CONVERT GROUP, not a +// file kind. Naming by kind said "2 PDFs" for a pdf-plus-txt selection that +// actually runs as one document batch, and the Run button said "2 files", +// which in a mixed queue hides which two. + +describe('groupNoun', () => { + it('names a document batch by its group, not by the first file kind', () => { + expect(groupNoun('doc', 2)).toBe('2 documents') + expect(groupNoun('doc', 1)).toBe('1 document') + }) + + it('gets every plural right, including the one that is not a plural', () => { + expect(groupNoun('image', 1)).toBe('1 image') + expect(groupNoun('image', 4)).toBe('4 images') + expect(groupNoun('video', 1)).toBe('1 video') + expect(groupNoun('video', 2)).toBe('2 videos') + // "2 audios" is not English; the noun carries the word "file". + expect(groupNoun('audio', 2)).toBe('2 audio files') + expect(groupNoun('archive', 3)).toBe('3 archives') + expect(groupNoun('sheet', 2)).toBe('2 spreadsheets') + expect(groupNoun('slide', 1)).toBe('1 slide deck') + }) + + it('falls back to a plain file count for an unknown group', () => { + expect(groupNoun('mystery', 1)).toBe('1 file') + expect(groupNoun('mystery', 3)).toBe('3 files') + }) +}) + +describe('GROUP_COLOR', () => { + it('colours every group the queue can produce', () => { + for (const g of ['image', 'video', 'audio', 'doc', 'sheet', 'slide', 'archive']) { + expect(GROUP_COLOR[g], g).toMatch(/^#[0-9a-f]{6}$/i) + } + }) +}) From e4799afb2a64bbb52595e64458cff82f43e8cfd3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:55:08 +0200 Subject: [PATCH 07/10] feat(nav): archive work becomes ordinary Convert; trim the options panel Owner feedback: - Tools always opens its grid. Landing back inside the last card hid every other tool. - The scope banner is gone, and options no longer appear at all until files are selected. Offering PNG/JPG targets with an empty queue invited a choice that could not be acted on. - Archive to PDF, PDF to CBZ and archive repack are Converts, not Tools: they turn a file into another file, which is what people go to Convert for. The Archives section and the Extract card are gone from Tools. - Both directions now exist: a PDF offers comic containers, an archive offers PDF, and either still offers its own family. - The window holds up at its 900x580 minimum: the queue was squeezed until filenames truncated to one letter, and the drop zone kept 212px it did not need once files existed. Routing now depends on the TARGET as well as the source, and targets are the intersection across the selection so a pdf+docx pair never offers CBZ. The page-render quality is pageQuality, not quality: one option bag can now reach either the convert tool (where quality is a preset STRING) or the archive tool, and Number of 'balanced' is NaN. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSq6HRwmaRKAv1t2hrdkJB --- e2e/workflows.spec.ts | 12 +- src/main/tools/registry.ts | 5 +- src/renderer/src/App.tsx | 43 +++-- src/renderer/src/components/DropZone.tsx | 20 +- src/renderer/src/components/OptionsPanel.tsx | 193 +++++++++++++------ src/renderer/src/components/TabRail.tsx | 2 +- src/renderer/src/state.ts | 43 ++++- src/shared/convert.ts | 45 ++++- src/shared/tabs.ts | 54 ++---- test/archive-live.test.ts | 2 +- test/convert-archive-targets.test.ts | 94 +++++++++ test/queues.test.ts | 11 +- test/tabs.test.ts | 40 ++-- 13 files changed, 413 insertions(+), 151 deletions(-) create mode 100644 test/convert-archive-targets.test.ts diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts index d5b6a2e..58461a7 100644 --- a/e2e/workflows.spec.ts +++ b/e2e/workflows.spec.ts @@ -169,12 +169,9 @@ test.afterAll(async () => { // --- UI navigation ----------------------------------------------------------- -/** Tools remembers the card you last had open (and that survives a restart via - * session.json), so reaching the grid means backing out of any open tool. */ +/** Tools always opens on its grid, however you last left it. */ async function openToolsGrid(p: Page): Promise { await p.locator('button:has-text("Tools")').first().click() - const back = p.locator('button[aria-label="Back to Tools"]') - if (await back.count()) await back.first().click() await expect(p.locator('h1', { hasText: 'Tools' }).first()).toBeVisible() } @@ -189,9 +186,12 @@ test('the rail lists every verb and opens its workspace', async () => { test('Tools groups its one-off verbs and opens one as a workspace', async () => { await openToolsGrid(page) - for (const t of ['Extract text', 'Merge', 'Burst', 'Archive to PDF', 'PDF to CBZ']) { + for (const t of ['Extract text', 'Pages to PNG', 'Merge', 'Split', 'Burst']) { await expect(page.locator(`text=${t}`).first()).toBeVisible() } + // Archive work is an ordinary Convert now, not a tool hidden in here. + await expect(page.locator('text=Archive to PDF')).toHaveCount(0) + await expect(page.locator('text=PDF to CBZ')).toHaveCount(0) await page.locator('button:has-text("Merge")').first().click() // A tool workspace is an ordinary queue titled with the tool's name. await expect(page.locator('h1', { hasText: 'Merge' }).first()).toBeVisible() @@ -579,7 +579,7 @@ test('pdf to-cbz: pages are zero-padded jpegs so a reader orders them correctly' format: '.cbz', dpi: 72, pageFormat: 'jpg', - quality: 80 + pageQuality: 80 }) expect(e.status).toBe('done') expect(e.outputPath!.endsWith('.cbz')).toBe(true) diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index 782a7fd..d68e63d 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -1295,7 +1295,10 @@ const archiveTool: ToolModule = { assertRarTarget(targetExt) const dpi = Math.max(36, Math.min(600, Number(options.dpi ?? 150))) const pageFormat = String(options.pageFormat ?? 'jpg') - const quality = Math.max(1, Math.min(100, Number(options.quality ?? 85))) + // `pageQuality`, not `quality`: on the Convert tab one option bag can + // reach either the convert tool (quality is a preset STRING) or this + // one, and Number('balanced') is NaN. + const quality = Math.max(1, Math.min(100, Number(options.pageQuality ?? 85))) // Always a neutral temp dir, so mutool draw's printf `-o` pattern can // never expand a `%` inherited from the source file's name. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d473c4e..e7cfbc0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -12,9 +12,9 @@ import type { FileInfo, FileKind, PreviewItem } from '@shared/types' import { canCompress, convertGroup, - familyFormats, isSameFormat, normalizeExt, + sharedTargets, toolForKind } from '@shared/convert' import { @@ -297,16 +297,23 @@ export default function App(): JSX.Element { // options panel can still show what it would do. const fallbackKind: FileKind = tab.kinds[0] ?? card?.kinds[0] ?? 'image' const optGroup = activeGroup ?? convertGroup(fallbackKind, '') - // The engine is resolved per group: the Convert tab runs the archive tool for - // a .cbz and the convert tool for everything else. - const engine = engineFor(state.tab, optGroup, card) - const tool = engine.tool - const curOptions = state.options[optionsKey(state.tab, state.activeTool, optGroup)] ?? { - ...defaultOptionsFor(state.tab, state.activeTool, optGroup) - } const srcNorms = new Set(selEff.map((f) => normalizeExt(f.ext))) const srcExts = [...srcNorms] // every selected source format (for greying targets) const sourceExt: string | null = srcNorms.size === 1 ? [...srcNorms][0] : null + const optKey = optionsKey(state.tab, state.activeTool, optGroup) + const curOptions = + state.options[optKey] ?? defaultOptionsFor(state.tab, state.activeTool, optGroup) + // On Convert the engine depends on the TARGET as well as the source: a .cbz + // to .cb7 is archive/repack, a .pdf to .cbz is archive/from-pdf, and a .png + // to .webp is the plain convert tool. + const engine = engineFor( + state.tab, + optGroup, + card, + activeKind && sourceExt ? { kind: activeKind, ext: sourceExt } : undefined, + String(curOptions.format ?? '') + ) + const tool = engine.tool // A source is runnable when idle (incl. already-done, so it can run again); a // result is always runnable — running it promotes its output back to input. @@ -322,9 +329,11 @@ export default function App(): JSX.Element { // against the selected group: Run only ever acts on one group. if (!accepts(f.kind)) return false if (activeGroup && groupOf(f) !== activeGroup) return false - if (tool === 'convert') { + if (state.tab === 'convert') { const fmt = String(curOptions.format ?? '') - return toolForKind(f.kind) != null && !isSameFormat(f.ext, fmt) + if (isSameFormat(f.ext, fmt)) return false + // Archive work routes to the archive tool, which has no toolForKind entry. + return tool === 'archive' || toolForKind(f.kind) != null } if (tool === 'compress') return canCompress(f.kind, f.ext) return true @@ -335,7 +344,7 @@ export default function App(): JSX.Element { useEffect(() => { if (!activeKind || tool !== 'convert') return const fmt = String(curOptions.format ?? '') - const opts = familyFormats(activeKind, sourceExt ?? '') + const opts = sharedTargets(activeKind, srcExts) const isSource = (ext: string): boolean => srcExts.some((e) => isSameFormat(ext, e)) const valid = opts.some((f) => f.ext === fmt) && !isSource(fmt) if (!valid) { @@ -345,6 +354,15 @@ export default function App(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeKind, sourceExt, srcExts.join('|'), curOptions.format, tool]) + // The chosen target decides the verb (repack / to-pdf / from-pdf), so keep the + // stored op in step with it. Without this a .cbz switched to .pdf would still + // run 'repack' and write a .pdf that is really a zip. + useEffect(() => { + if (state.tab !== 'convert' || !engine.op) return + if (curOptions.op === engine.op) return + dispatch({ type: 'setOption', group: optGroup, key: 'op', value: engine.op }) + }, [engine.op, curOptions.op, optGroup, state.tab]) + function onItemClick(id: string, e: MouseEvent): void { const item = cur.items.find((i) => i.id === id) if (!item) return @@ -894,7 +912,7 @@ export default function App(): JSX.Element { <>
{ e.preventDefault() if (tool !== 'generate' && !onToolsGrid) setDragging(true) @@ -1019,6 +1037,7 @@ export default function App(): JSX.Element { {!onToolsGrid && ( - - + {/* The icon is the empty state's anchor; once files exist the zone is a + slim target and the glyph would just eat the queue's room. */} + +
-
{label}
+
+ {label} +
or browse
diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx index 50d48f0..1dd1e7b 100644 --- a/src/renderer/src/components/OptionsPanel.tsx +++ b/src/renderer/src/components/OptionsPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react' import type { FileKind, JobOptions } from '@shared/types' -import { familyFormats, isSameFormat, type FormatOption } from '@shared/convert' +import { familyFormats, isSameFormat, sharedTargets, type FormatOption } from '@shared/convert' import { AUDIO_BITRATES, AUDIO_CODECS, @@ -18,13 +18,14 @@ import { type UpscaleModel } from '@shared/compress' import { ARCHIVE_FORMATS, COMIC_FORMATS, needsRar } from '@shared/archive' +import type { TabId } from '@shared/tabs' import { RESIZE_FITS, type ResizeFit } from '@shared/resize' import { BG_DEFAULTS, BG_FILLS, type BgFill } from '@shared/removebg' import { GEN_SIZES, GEN_STYLES, GEN_MAX_COUNT, clampDim } from '@shared/generate' import { archInfoFor, type GenArch, type GenModel } from '@shared/genArch' import { Icon } from './Icon' import type { ToolId } from '@shared/types' -import { GROUP_COLOR, groupNoun } from './queueGroups' +import { groupNoun } from './queueGroups' import { PidInstallCard } from './PidUpscale' import { usePidStatus } from './usePidStatus' import { ComfyImportCard } from './ComfyImport' @@ -93,16 +94,25 @@ function ConvertOptions({ activeKind, sourceExt, srcExts, + verb, + hasRar, set }: { options: JobOptions activeKind: FileKind | null sourceExt: string | null srcExts: string[] - set: (k: string, v: string | number) => void + /** The archive verb this target resolves to, if any: repack / to-pdf / from-pdf. */ + verb?: string + hasRar: boolean + set: (k: string, v: string | number | boolean) => void }): JSX.Element { const kind = activeKind ?? 'image' - const formats = familyFormats(kind, sourceExt ?? '') + // Targets valid for EVERY selected source, so a mixed pdf+docx selection + // never offers CBZ (which only the pdf could do). + const formats = srcExts.length + ? sharedTargets(kind, srcExts) + : familyFormats(kind, sourceExt ?? '') return ( <>
@@ -111,13 +121,21 @@ function ConvertOptions({ {formats.map((f) => { // Grey out (and block) any format a selected source already is: with // a PNG + JPEG selection, neither PNG nor JPEG is a valid target. - const disabled = srcExts.some((e) => isSameFormat(f.ext, e)) + const isSource = srcExts.some((e) => isSameFormat(f.ext, e)) + const noRar = needsRar(f.ext) && !hasRar + const disabled = isSource || noRar const sel = options.format === f.ext && !disabled return (
)} + {/* Repacking an archive: the pages are already compressed images, so + storing them is the same size and faster. */} + {verb === 'repack' && ( +
+ + set('store', v === 'store')} + options={[ + { value: 'store', label: 'Store' }, + { value: 'normal', label: 'Normal' } + ]} + /> +

+ Comic pages are already compressed images, so Store is faster at the same size. +

+
+ )} + {/* PDF into a comic archive: its pages have to be rendered first. */} + {verb === 'from-pdf' && } + + ) +} + +/** Page-rendering controls shared by every route that turns a PDF into images. */ +function PageRenderOptions({ + options, + set +}: { + options: JobOptions + set: (k: string, v: string | number | boolean) => void +}): JSX.Element { + const pageFormat = String(options.pageFormat ?? 'jpg') + return ( + <> +
+
+ + Resolution + + + {Number(options.dpi ?? 150)} DPI + +
+ set('dpi', Number(e.target.value))} + className="w-full accent-accent" + /> +
+
+ + set('pageFormat', v)} + options={[ + { value: 'jpg', label: 'JPEG' }, + { value: 'png', label: 'PNG' } + ]} + /> +

+ {pageFormat === 'jpg' + ? 'Much smaller files, the usual choice for comics.' + : 'Lossless, but a long comic runs to hundreds of megabytes.'} +

+
+ {pageFormat === 'jpg' && ( + + )} ) } @@ -191,12 +282,18 @@ function ChoiceSelect({ function QualitySlider({ options, - set + set, + field = 'quality', + fallback = 80 }: { options: JobOptions set: (k: string, v: string | number | boolean) => void + /** Which option this drives. Page rendering uses `pageQuality` so it cannot + * collide with convert's preset `quality` in a shared option bag. */ + field?: string + fallback?: number }): JSX.Element { - const q = Number(options.quality ?? 80) + const q = Number(options[field] ?? fallback) return (
@@ -208,7 +305,7 @@ function QualitySlider({ min={10} max={100} value={q} - onChange={(e) => set('quality', Number(e.target.value))} + onChange={(e) => set(field, Number(e.target.value))} className="w-full accent-accent" />
@@ -1630,40 +1727,8 @@ function GenerateOptions({ ) } -/** What these settings will act on. The queue can hold images and video at the - * same time, so Run has to say which of them it means. Named by CONVERT GROUP, - * not file kind: a pdf plus a txt is "2 documents", because they run as one - * batch under one target set. */ -function ScopeChip({ - group, - count, - known -}: { - group: string - count: number - known: boolean -}): JSX.Element { - return ( -
- - - {known && count > 0 ? ( - <> - Applies to the {groupNoun(group, count)}{' '} - selected. - - ) : ( - <>Select files to choose what these settings apply to. - )} - -
- ) -} - export function OptionsPanel({ + tab, tool, label, options, @@ -1681,6 +1746,8 @@ export function OptionsPanel({ onSet, onRun }: { + /** The rail tab this workspace belongs to. */ + tab: TabId /** The engine tool this workspace runs. */ tool: ToolId /** The verb's own name, used by the Run button. */ @@ -1703,6 +1770,13 @@ export function OptionsPanel({ onSet: (k: string, v: string | number | boolean) => void onRun: () => void }): JSX.Element { + // Generate is the one tab whose options exist without any file selected: its + // prompt IS the input. + const showOptions = tool === 'generate' || activeGroup != null + // On Convert, ONE component owns the target grid for every route, so a .pdf + // aimed at .cbz still picks its target in the same place a .png does. + const isConvertTab = tab === 'convert' + const { rar } = useArchiveStatus() return ( // Every option control (slider, toggle, run button, selected chip) reads the // accent variables, so overriding them here themes the whole panel black in @@ -1712,7 +1786,7 @@ export function OptionsPanel({ // used to be an ordinary last child of one scroll container and left the // viewport entirely at the app's own default window size.