diff --git a/.prettierignore b/.prettierignore index 0e252b7..1f1d8b1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,6 @@ patches # Design and plan documents: their code blocks are partial snippets, not code. docs/superpowers + +# Git worktrees of other branches (each formats and lints itself). +.claude/worktrees diff --git a/docs/mockups/cli-settings-skill.png b/docs/mockups/cli-settings-skill.png index b46a0be..06ef192 100644 Binary files a/docs/mockups/cli-settings-skill.png and b/docs/mockups/cli-settings-skill.png differ diff --git a/docs/mockups/generate-empty/after.png b/docs/mockups/generate-empty/after.png new file mode 100644 index 0000000..41743f4 Binary files /dev/null and b/docs/mockups/generate-empty/after.png differ diff --git a/docs/mockups/generate-empty/before.png b/docs/mockups/generate-empty/before.png new file mode 100644 index 0000000..1aa97d9 Binary files /dev/null and b/docs/mockups/generate-empty/before.png differ diff --git a/docs/mockups/generate-empty/settings.png b/docs/mockups/generate-empty/settings.png new file mode 100644 index 0000000..c7f4a76 Binary files /dev/null and b/docs/mockups/generate-empty/settings.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-10-s2.png b/docs/mockups/terminal-v5/shots/impl-10-s2.png index cdbb309..c72acfc 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-10-s2.png and b/docs/mockups/terminal-v5/shots/impl-10-s2.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-collapsed.png b/docs/mockups/terminal-v5/shots/impl-collapsed.png index 2366a40..6648ad5 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-collapsed.png and b/docs/mockups/terminal-v5/shots/impl-collapsed.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-completed.png b/docs/mockups/terminal-v5/shots/impl-completed.png index 4ec458d..2eee284 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-completed.png and b/docs/mockups/terminal-v5/shots/impl-completed.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-empty.png b/docs/mockups/terminal-v5/shots/impl-empty.png index 9a8e65c..1190eb9 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-empty.png and b/docs/mockups/terminal-v5/shots/impl-empty.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-generate.png b/docs/mockups/terminal-v5/shots/impl-generate.png index 14dce8e..094bcbf 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-generate.png and b/docs/mockups/terminal-v5/shots/impl-generate.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-info.png b/docs/mockups/terminal-v5/shots/impl-info.png index 4a78eb7..b044506 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-info.png and b/docs/mockups/terminal-v5/shots/impl-info.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-menu.png b/docs/mockups/terminal-v5/shots/impl-menu.png index 5e2f643..e1137f1 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-menu.png and b/docs/mockups/terminal-v5/shots/impl-menu.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-preview.png b/docs/mockups/terminal-v5/shots/impl-preview.png index 106a7fd..f77f57c 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-preview.png and b/docs/mockups/terminal-v5/shots/impl-preview.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-settings.png b/docs/mockups/terminal-v5/shots/impl-settings.png index 711a975..617339a 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-settings.png and b/docs/mockups/terminal-v5/shots/impl-settings.png differ diff --git a/docs/mockups/terminal-v5/shots/impl-tools.png b/docs/mockups/terminal-v5/shots/impl-tools.png index 5c8fb2b..615f6d9 100644 Binary files a/docs/mockups/terminal-v5/shots/impl-tools.png and b/docs/mockups/terminal-v5/shots/impl-tools.png differ diff --git a/docs/mockups/view-zoom/shots/impl-menu.png b/docs/mockups/view-zoom/shots/impl-menu.png index ed83b3a..de937d2 100644 Binary files a/docs/mockups/view-zoom/shots/impl-menu.png and b/docs/mockups/view-zoom/shots/impl-menu.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size0.png b/docs/mockups/view-zoom/shots/impl-size0.png index a39c776..f70099e 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size0.png and b/docs/mockups/view-zoom/shots/impl-size0.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size1.png b/docs/mockups/view-zoom/shots/impl-size1.png index 49a52ff..85d8485 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size1.png and b/docs/mockups/view-zoom/shots/impl-size1.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size2.png b/docs/mockups/view-zoom/shots/impl-size2.png index 60e3239..4020db1 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size2.png and b/docs/mockups/view-zoom/shots/impl-size2.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size3.png b/docs/mockups/view-zoom/shots/impl-size3.png index 3ab1f04..c8163a7 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size3.png and b/docs/mockups/view-zoom/shots/impl-size3.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size4.png b/docs/mockups/view-zoom/shots/impl-size4.png index bd9ab59..e2ea15a 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size4.png and b/docs/mockups/view-zoom/shots/impl-size4.png differ diff --git a/docs/mockups/view-zoom/shots/impl-size5.png b/docs/mockups/view-zoom/shots/impl-size5.png index ea19118..48fcf8e 100644 Binary files a/docs/mockups/view-zoom/shots/impl-size5.png and b/docs/mockups/view-zoom/shots/impl-size5.png differ diff --git a/e2e/cli-packed.spec.ts b/e2e/cli-packed.spec.ts index cddbf43..911f53d 100644 --- a/e2e/cli-packed.spec.ts +++ b/e2e/cli-packed.spec.ts @@ -102,7 +102,8 @@ test('Ctrl+C in a console cancels cleanly: exit 130, no ffmpeg, no partial outpu ]) const result = join(work, 'result.json') const before = ffmpegPids() - // Start-Process gives the helper a console of its own (minimized). It joins + // Start-Process gives the helper a console of its own, hidden so the run never + // shows a window or takes focus (console input/output work the same). It joins // -ArgumentList with spaces, so every value carries its own quotes. const q = (s: string): string => `'"${s}"'` const helperArgs = [ @@ -125,9 +126,9 @@ test('Ctrl+C in a console cancels cleanly: exit 130, no ffmpeg, no partial outpu [ '-NoProfile', '-Command', - `Start-Process powershell.exe -Wait -WindowStyle Minimized -ArgumentList @(${helperArgs})` + `Start-Process powershell.exe -Wait -WindowStyle Hidden -ArgumentList @(${helperArgs})` ], - { encoding: 'utf-8', timeout: 170_000 } + { encoding: 'utf-8', timeout: 170_000, windowsHide: true } ) expect(r.status, r.stderr).toBe(0) const out = JSON.parse(readFileSync(result, 'utf-8').replace(/^\uFEFF/, '')) as { diff --git a/e2e/cli.spec.ts b/e2e/cli.spec.ts index 10ca8b7..ff15899 100644 --- a/e2e/cli.spec.ts +++ b/e2e/cli.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test' -import { _electron } from 'playwright' import { execFileSync, spawn, spawnSync } from 'child_process' import { createHash } from 'crypto' import { @@ -14,7 +13,7 @@ import { } from 'fs' import { tmpdir } from 'os' import { dirname, join } from 'path' -import { FFMPEG, MAGICK, MUTOOL, ROOT, magickEnv } from './helpers' +import { FFMPEG, MAGICK, MUTOOL, ROOT, magickEnv, launchApp } from './helpers' // The CLI as a real process (spec 8.2): `node out/main/cli.js` against the // repo's bundled tools, with an isolated userData. Run `npm run build` first. @@ -306,10 +305,7 @@ test('a CLI killed outright leaves no partial output and no ffmpeg (watchdog)', test('works while the app is open, and its jobs never reach the app', async () => { const ud = join(work, '.ud') - const app = await _electron.launch({ - args: [ROOT], - env: { ...process.env, FILESMITH_USER_DATA: ud } - }) + const app = await launchApp({ FILESMITH_USER_DATA: ud }) try { const page = await app.firstWindow() image('cliprobe.png') diff --git a/e2e/generate-empty.spec.ts b/e2e/generate-empty.spec.ts new file mode 100644 index 0000000..16f54a9 --- /dev/null +++ b/e2e/generate-empty.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { MAIN, ROOT, launchApp } from './helpers' + +// Generate with nothing set up: a fresh userData and a throwaway home folder +// (homedir() follows USERPROFILE, so ComfyUI discovery under the real profile +// finds nothing). The tab must explain itself instead of showing an empty +// model control and a silently disabled Generate. +const SHOTS = join(ROOT, 'docs', 'mockups', 'generate-empty') + +test('Generate without a model shows one next step and says why it cannot run', async () => { + test.skip(!existsSync(MAIN), 'run `npm run build` first') + const profile = mkdtempSync(join(tmpdir(), 'fs-profile-')) + mkdirSync(join(profile, 'Downloads')) + const userData = mkdtempSync(join(tmpdir(), 'fs-ud-')) + const app = await launchApp({ USERPROFILE: profile, FILESMITH_USER_DATA: userData }) + try { + const page = await app.firstWindow() + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900)) + const nav = page.getByRole('navigation', { name: 'Operations' }) + await nav.getByRole('button', { name: 'Generate' }).click() + const insp = page.getByRole('complementary', { name: 'Inspector' }) + await expect(insp.getByText('No image model yet')).toBeVisible() + await expect(page.getByTestId('run')).toBeDisabled() + await expect(insp.getByText('Choose your ComfyUI folder to generate')).toBeVisible() + // A prompt alone does not turn it on: there is still nothing to run with. + const prompt = page.getByRole('textbox', { name: 'Prompt', exact: true }) + await prompt.fill('a lighthouse at dusk') + await expect(page.getByTestId('run')).toBeDisabled() + await prompt.fill('') + // No toolbar row and no Generate button there: Run lives in the inspector + // footer only, and the prompt sits flush at the top of the centre column. + await expect(page.getByRole('toolbar', { name: 'Generate actions' })).toHaveCount(0) + if (process.env['FILESMITH_SHOTS']) { + mkdirSync(SHOTS, { recursive: true }) + await page.screenshot({ path: join(SHOTS, 'after.png') }) + await nav.getByRole('button', { name: 'Settings' }).click() + await expect(page.getByText('Not set')).toBeVisible() + await expect(page.getByRole('button', { name: 'Choose folder' })).toBeVisible() + await page.screenshot({ path: join(SHOTS, 'settings.png') }) + } + } finally { + await app.close() + rmSync(profile, { recursive: true, force: true }) + rmSync(userData, { recursive: true, force: true }) + } +}) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 084bc53..2899268 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,4 +1,4 @@ -import type { Page } from 'playwright' +import { _electron, type ElectronApplication, type Page } from 'playwright' import { join, resolve } from 'path' import type { JobEvent } from '../src/shared/types' @@ -19,6 +19,18 @@ export const magickEnv = { MAGICK_CONFIGURE_PATH: BIN } +/** Launch the built app for a spec. Every spec goes through here so the window + * stays hidden (FILESMITH_E2E_HIDDEN): it paints for screenshots but is never + * shown or focused, so a test run never steals focus from the desktop. */ +export function launchApp( + env: Record = {} +): Promise { + return _electron.launch({ + args: [ROOT], + env: { ...process.env, ...env, FILESMITH_E2E_HIDDEN: '1' } as Record + }) +} + /** Run one job through the real preload bridge; resolve on its terminal event. */ export function runJob( page: Page, diff --git a/e2e/rowmenu.spec.ts b/e2e/rowmenu.spec.ts index 41dc81d..b7b8e30 100644 --- a/e2e/rowmenu.spec.ts +++ b/e2e/rowmenu.spec.ts @@ -1,9 +1,9 @@ -import { _electron, type ElectronApplication, type Page } from 'playwright' +import { type ElectronApplication, type Page } from 'playwright' import { test, expect } from '@playwright/test' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { MAIN, ROOT } from './helpers' +import { MAIN, launchApp } from './helpers' // The files table's right-click menu and the one-file inspector panes, on a // seeded session. Nothing here deletes a file: Delete is only opened and @@ -39,10 +39,7 @@ test.beforeAll(async () => { queues: { convert: { items } } } writeFileSync(join(userData, 'session.json'), JSON.stringify(session)) - app = await _electron.launch({ - args: [ROOT], - env: { ...process.env, FILESMITH_USER_DATA: userData } - }) + app = await launchApp({ FILESMITH_USER_DATA: userData }) page = await app.firstWindow() await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900)) await expect(row('alpha')).toBeVisible() diff --git a/e2e/skill.spec.ts b/e2e/skill.spec.ts index ebf5eac..0fba98b 100644 --- a/e2e/skill.spec.ts +++ b/e2e/skill.spec.ts @@ -1,9 +1,8 @@ -import { _electron } from 'playwright' import { test, expect } from '@playwright/test' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { MAIN, ROOT } from './helpers' +import { MAIN, ROOT, launchApp } from './helpers' // The Settings button (M10) against a throwaway home folder: homedir() follows // USERPROFILE on Windows, so the real ~/.claude is never touched. @@ -17,10 +16,7 @@ test('Settings installs the Claude skill and shows its version', async () => { const version = ( JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')) as { version: string } ).version - const app = await _electron.launch({ - args: [ROOT], - env: { ...process.env, USERPROFILE: profile, FILESMITH_USER_DATA: userData } - }) + const app = await launchApp({ USERPROFILE: profile, FILESMITH_USER_DATA: userData }) try { const page = await app.firstWindow() await page diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 18cf69f..efa68c7 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,4 +1,5 @@ -import { _electron, type ElectronApplication, type Page } from 'playwright' +import { type ElectronApplication, type Page } from 'playwright' +import { launchApp } from './helpers' import { test, expect } from '@playwright/test' import { execFileSync } from 'child_process' import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs' @@ -38,7 +39,7 @@ test.beforeAll(async () => { // makes app.getAppPath() resolve to out/main, which silently hides every // resources/ tree and lets resolveTool fall back to PATH binaries — the // suite would then test the machine's tools, not the bundled ones. - app = await _electron.launch({ args: [ROOT] }) + app = await launchApp() page = await app.firstWindow() }) diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index 1a7b145..550f584 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -1,9 +1,9 @@ -import { _electron, type ElectronApplication, type Locator, type Page } from 'playwright' +import { type ElectronApplication, type Locator, type Page } from 'playwright' import { test, expect } from '@playwright/test' import { existsSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { MAIN, ROOT } from './helpers' +import { MAIN, launchApp } from './helpers' // UI behaviour the unit suite cannot reach: the sidebar, the inspector tabs and // the empty workspace. A private userData keeps the user's real session and @@ -15,10 +15,7 @@ let userData: string test.beforeAll(async () => { test.skip(!existsSync(MAIN), 'run `npm run build` first') userData = mkdtempSync(join(tmpdir(), 'filesmith-ui-')) - app = await _electron.launch({ - args: [ROOT], - env: { ...process.env, FILESMITH_USER_DATA: userData } - }) + app = await launchApp({ FILESMITH_USER_DATA: userData }) page = await app.firstWindow() await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900)) }) diff --git a/e2e/viewsizes.spec.ts b/e2e/viewsizes.spec.ts index ad98728..87af385 100644 --- a/e2e/viewsizes.spec.ts +++ b/e2e/viewsizes.spec.ts @@ -1,10 +1,10 @@ -import { _electron, type ElectronApplication, type Page } from 'playwright' +import { type ElectronApplication, type Page } from 'playwright' import { test, expect } from '@playwright/test' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { execFileSync } from 'child_process' -import { FFMPEG, MAGICK, MAIN, ROOT, magickEnv } from './helpers' +import { FFMPEG, MAGICK, MAIN, magickEnv, launchApp } from './helpers' // View sizes (spec docs/superpowers/specs/2026-10-05-view-sizes-design.md) on a // seeded session: four images and a video, so there are two group headers. @@ -44,10 +44,7 @@ test.beforeAll(async () => { queues: { convert: { items } } } writeFileSync(join(userData, 'session.json'), JSON.stringify(session)) - app = await _electron.launch({ - args: [ROOT], - env: { ...process.env, FILESMITH_USER_DATA: userData } - }) + app = await launchApp({ FILESMITH_USER_DATA: userData }) page = await app.firstWindow() await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900)) await expect(row('alpha')).toBeVisible() diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index c6ad742..8ddbf4f 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -1,10 +1,10 @@ -import { _electron, type ElectronApplication, type Page } from 'playwright' +import { type ElectronApplication, type Page } from 'playwright' import { test, expect } from '@playwright/test' import { execFileSync } from 'child_process' import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { MAGICK, MAIN, ROOT, magickEnv } from './helpers' +import { MAGICK, MAIN, ROOT, magickEnv, launchApp } from './helpers' // Screenshots for the owner's side-by-side with the signed-off mockup. No pixel // assertions (spec 7.3): the shots are evidence, not a gate. @@ -93,10 +93,7 @@ test.beforeAll(async () => { } } writeFileSync(join(userData, 'session.json'), JSON.stringify(session)) - app = await _electron.launch({ - args: [ROOT], - env: { ...process.env, FILESMITH_USER_DATA: userData } - }) + app = await launchApp({ FILESMITH_USER_DATA: userData }) page = await app.firstWindow() await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 900)) await expect( diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts index bc301c0..d66a65d 100644 --- a/e2e/workflows.spec.ts +++ b/e2e/workflows.spec.ts @@ -1,4 +1,4 @@ -import { _electron, type ElectronApplication, type Page } from 'playwright' +import { type ElectronApplication, type Page } from 'playwright' import { test, expect } from '@playwright/test' import { execFileSync } from 'child_process' import { @@ -12,7 +12,17 @@ import { } from 'fs' import { tmpdir } from 'os' import { basename, join } from 'path' -import { FFMPEG, FFPROBE, MAGICK, MAIN, MUTOOL, ROOT, SEVENZIP, magickEnv, runJob } from './helpers' +import { + FFMPEG, + FFPROBE, + MAGICK, + MAIN, + MUTOOL, + SEVENZIP, + magickEnv, + runJob, + launchApp +} from './helpers' // The full workflow matrix, run against the REAL built app: every operation of // every category, through the actual preload bridge -> IPC -> queue -> engine @@ -157,7 +167,7 @@ test.beforeAll(async () => { // makes app.getAppPath() resolve to out/main, which silently hides every // resources/ tree and lets resolveTool fall back to PATH binaries — the // suite would then test the machine's tools, not the bundled ones. - app = await _electron.launch({ args: [ROOT] }) + app = await launchApp() page = await app.firstWindow() }) diff --git a/eslint.config.mjs b/eslint.config.mjs index c709622..eb8fc85 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,7 +10,16 @@ export default tseslint.config( // build/installer is the NSIS setup kit (ported from Prism): CommonJS // generator scripts + committed media that follow that kit's conventions, // not the app toolchain's. - ignores: ['out', 'dist', 'node_modules', 'resources', '.harness', 'build/installer', 'patches'] + ignores: [ + 'out', + 'dist', + 'node_modules', + 'resources', + '.harness', + 'build/installer', + 'patches', + '.claude/worktrees' + ] }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/package-lock.json b/package-lock.json index 0f2f548..dc3484b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filesmith", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filesmith", - "version": "0.7.0", + "version": "0.7.1", "hasInstallScript": true, "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index a3f7a80..8d7ca5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filesmith", - "version": "0.7.0", + "version": "0.7.1", "description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.", "author": "Max", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 4a55267..338a8c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,10 @@ import { bootEngine } from './boot' const userDataOverride = process.env['FILESMITH_USER_DATA'] if (userDataOverride) app.setPath('userData', userDataOverride) +// Test hook: e2e runs the window hidden so a test run never shows a window or +// steals focus. It still paints (screenshots work) and is never shown/focused. +const e2eHidden = process.env['FILESMITH_E2E_HIDDEN'] === '1' + // The engine's view of its host (spec M1). Read after the e2e userData override // so tests that seed a session still get their temp folder. setEngineEnv({ @@ -137,13 +141,15 @@ function createWindow(): void { // Frameless: the renderer draws the 32px title bar and window controls. frame: false, backgroundColor: '#0a0a0a', + ...(e2eHidden ? { paintWhenInitiallyHidden: true, skipTaskbar: true } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), - sandbox: false + sandbox: false, + ...(e2eHidden ? { backgroundThrottling: false } : {}) } }) - mainWindow.on('ready-to-show', () => mainWindow.show()) + if (!e2eHidden) mainWindow.on('ready-to-show', () => mainWindow.show()) // Open external links in the OS browser, never in-app. mainWindow.webContents.setWindowOpenHandler((details) => { @@ -182,7 +188,7 @@ if (!app.requestSingleInstanceLock()) { } else { app.on('second-instance', () => { const win = BrowserWindow.getAllWindows()[0] - if (win) { + if (win && !e2eHidden) { if (win.isMinimized()) win.restore() win.focus() } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6bdd5b4..1f647f9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -85,6 +85,8 @@ import { SettingsView } from './components/views/SettingsView' import { ToolsView } from './components/views/ToolsView' import type { GenerateOptions } from '@shared/generate' import { ContextMenu, type MenuState } from './components/ContextMenu' +import { useGenerateStatus } from './components/options/hooks/useGenerateStatus' +import { genBlockReason } from './components/options/generate/genReady' import { ConfirmDialog, type ConfirmState } from './components/ConfirmDialog' const extOfPath = (p: string): string => { @@ -119,6 +121,8 @@ export default function App(): JSX.Element { }>({ running: false, slots: [] }) const genIdRef = useRef(0) const genActiveId = useRef(null) + // Model availability, shared with the inspector (one scan, one refresh). + const { status: genStatus } = useGenerateStatus() // Model, size and seed of each image generated this session, for Info. // State, not a ref: Info reads it during render. const [genMeta, setGenMeta] = useState< @@ -883,9 +887,12 @@ export default function App(): JSX.Element { 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)}` + // No model yet (fresh install, no ComfyUI folder): Generate stays off and + // says why, instead of failing after the user writes a prompt. + const genBlocked = tool === 'generate' ? genBlockReason(genStatus) : null const runCount = tool === 'generate' - ? promptFilled && !genRun.running + ? promptFilled && !genRun.running && !genBlocked ? 1 : 0 : isMerge && runList.length < 2 @@ -1207,12 +1214,8 @@ export default function App(): JSX.Element { slots={genRun.slots} results={genResults} aspect={genAspect} - canRun={runCount > 0} + blocked={genBlocked} focused={genFocus} - onRun={() => void run()} - onCancel={() => { - if (genActiveId.current) window.filesmith.generateCancel(genActiveId.current) - }} onFocus={previewGen} onOpen={(p) => window.filesmith.openFile(p)} onMenu={openGenMenu} @@ -1278,6 +1281,7 @@ export default function App(): JSX.Element { sub={inspSub} runLabel={runLabel} runDisabled={runCount === 0} + runHint={genBlocked} onRun={() => void run()} stopping={tool === 'generate' ? genRun.running : inFlight.length > 0} onStop={() => { diff --git a/src/renderer/src/components/inspector/Inspector.tsx b/src/renderer/src/components/inspector/Inspector.tsx index 09f3d98..e7afb94 100644 --- a/src/renderer/src/components/inspector/Inspector.tsx +++ b/src/renderer/src/components/inspector/Inspector.tsx @@ -13,6 +13,7 @@ export function Inspector({ sub, runLabel, runDisabled, + runHint = null, onRun, stopping = false, onStop, @@ -24,6 +25,8 @@ export function Inspector({ sub: string runLabel: string runDisabled: boolean + /** Why Run is off, when the reason is not obvious (Generate with no model). */ + runHint?: string | null onRun: () => void /** While work runs, Run becomes Stop (owner, 2026-10-04). */ stopping?: boolean @@ -55,6 +58,11 @@ export function Inspector({ {children} + {runHint && !stopping && ( +
+ {runHint} +
+ )}
{stopping ? ( diff --git a/src/renderer/src/components/options/generate/AddModel.tsx b/src/renderer/src/components/options/generate/AddModel.tsx index e69f6d8..969f5bf 100644 --- a/src/renderer/src/components/options/generate/AddModel.tsx +++ b/src/renderer/src/components/options/generate/AddModel.tsx @@ -11,10 +11,13 @@ import { Setting } from '../../ui/Setting' */ export function AddModel({ onAdded, - comfyFolder + comfyFolder, + empty = false }: { onAdded: () => void comfyFolder?: string | null + /** No image model at all: lead with the one next step (the ComfyUI folder). */ + empty?: boolean }): JSX.Element { const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null) const pickComfy = (): void => { @@ -41,6 +44,42 @@ export function AddModel({ onAdded() }) } + const folderLabel = comfyFolder ? 'Change ComfyUI folder' : 'Choose ComfyUI folder' + const status = + msg && + (msg.ok ? ( +
{msg.text}
+ ) : ( +
+ + {msg.text} +
+ )) + if (empty) + return ( + + No image models in {comfyFolder}. Add one there, or add a model. + + ) : ( + 'Filesmith generates with the models in your ComfyUI folder.' + ) + } + > +
+ + {folderLabel} + + + Add a model + +
+ {status} +
+ ) return (
- Change ComfyUI folder + {folderLabel} - {msg && - (msg.ok ? ( -
{msg.text}
- ) : ( -
- - {msg.text} -
- ))} + {status} ) } diff --git a/src/renderer/src/components/options/generate/GenerateSettings.tsx b/src/renderer/src/components/options/generate/GenerateSettings.tsx index 0cbce2b..d2c37aa 100644 --- a/src/renderer/src/components/options/generate/GenerateSettings.tsx +++ b/src/renderer/src/components/options/generate/GenerateSettings.tsx @@ -79,35 +79,44 @@ export function GenerateSettings({ ] const needsFiles = Boolean(selected && !selected.runnable && selected.missing?.length) const unrunnable = selected && !selected.runnable && !needsFiles ? selected : null + // Scan answered with nothing: one calm block with the next step, instead of + // a "not found" warning, an empty Model row and a "Change" for no folder. + const noModel = Boolean(status && !models.length) return ( <> - {status && !status.available && } - - - {models.length > 0 && ( - set('model', v)} /> - )} - {/* An unrecognised model is not a forbidden one: send it through a + {status && !status.available && !noModel && } + {noModel ? ( + + + + ) : ( + + + {models.length > 0 && ( + set('model', v)} /> + )} + {/* An unrecognised model is not a forbidden one: send it through a generic graph and let ComfyUI give its own verdict. */} - {unrunnable?.reason && unrunnable.tryAnyway && ( - set('tryAnyway', tryAnyway ? 0 : 1)} - > - {tryAnyway ? 'Will try anyway, click to cancel' : 'Try anyway'} - - )} - - {selected && needsFiles && } - - + {unrunnable?.reason && unrunnable.tryAnyway && ( + set('tryAnyway', tryAnyway ? 0 : 1)} + > + {tryAnyway ? 'Will try anyway, click to cancel' : 'Try anyway'} + + )} + + {selected && needsFiles && } + + + )} {/* Negative prompt only affects arches that use real CFG (SDXL). At cfg 1 the negative branch is inert, so it is hidden there. */} diff --git a/src/renderer/src/components/options/generate/genReady.ts b/src/renderer/src/components/options/generate/genReady.ts new file mode 100644 index 0000000..d3a48ee --- /dev/null +++ b/src/renderer/src/components/options/generate/genReady.ts @@ -0,0 +1,10 @@ +/** Why Generate cannot run yet, or null when a model is there. Unknown status + * (the scan has not answered) is not a reason: never block on a guess. */ +export function genBlockReason( + status: { models: unknown[]; comfyFolder?: string | null } | null +): string | null { + if (!status || status.models.length > 0) return null + return status.comfyFolder + ? 'Add an image model to generate' + : 'Choose your ComfyUI folder to generate' +} diff --git a/src/renderer/src/components/options/hooks/useGenerateStatus.ts b/src/renderer/src/components/options/hooks/useGenerateStatus.ts index eb50bee..e062180 100644 --- a/src/renderer/src/components/options/hooks/useGenerateStatus.ts +++ b/src/renderer/src/components/options/hooks/useGenerateStatus.ts @@ -1,23 +1,30 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import type { GenModelScan } from '@shared/genArch' export type GenerateStatus = { available: boolean } & GenModelScan +// One shared answer: the inspector refreshes it after a folder pick, and the +// Run button (App) must see that same refresh to stop saying "no model". +let latest: GenerateStatus | null = null +const listeners = new Set<(s: GenerateStatus) => void>() + +function fetchStatus(): void { + void window.filesmith.generateStatus().then((s) => { + latest = s + for (const l of listeners) l(s) + }) +} + /** Whether generation is available (a ComfyUI is findable) + the models. */ export function useGenerateStatus(): { status: GenerateStatus | null; refresh: () => void } { - const [status, setStatus] = useState(null) - const alive = useRef(true) + const [status, setStatus] = useState(latest) useEffect(() => { - alive.current = true + listeners.add(setStatus) + fetchStatus() return () => { - alive.current = false + listeners.delete(setStatus) } }, []) - const refresh = useCallback(() => { - void window.filesmith.generateStatus().then((s) => { - if (alive.current) setStatus(s) - }) - }, []) - useEffect(() => refresh(), [refresh]) + const refresh = useCallback(() => fetchStatus(), []) return { status, refresh } } diff --git a/src/renderer/src/components/views/GenerateView.tsx b/src/renderer/src/components/views/GenerateView.tsx index 54fc688..36f28a1 100644 --- a/src/renderer/src/components/views/GenerateView.tsx +++ b/src/renderer/src/components/views/GenerateView.tsx @@ -1,5 +1,4 @@ import type { JSX } from 'react' -import { Icon } from '../icons/Icon' import { EmptyState } from '../queue/EmptyState' import { ProgressBar } from '../ui/ProgressBar' @@ -12,10 +11,8 @@ export function GenerateView({ slots, results, aspect, - canRun, + blocked, focused, - onRun, - onCancel, onFocus, onOpen, onMenu @@ -26,10 +23,9 @@ export function GenerateView({ slots: { pct: number; path?: string }[] results: string[] aspect: string - canRun: boolean + /** Why Generate cannot run yet (no model), shown in the empty state. */ + blocked: string | null focused: string | null - onRun: () => void - onCancel: () => void onFocus: (path: string) => void onOpen: (path: string) => void onMenu: (path: string, x: number, y: number) => void @@ -53,25 +49,9 @@ export function GenerateView({ ) return ( <> -
- - {running && ( - - )} -
-
+ {/* No toolbar row: the prompt sits flush under the title bar, and Run and + Stop live in the inspector footer only (owner, 2026-10-05). */} +