diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index ffc774c7..b05db987 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -15,6 +15,8 @@ import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflo import { validateWorkflowPreflight } from '@areas/workflows/preflight' import type { WorkflowExtension } from '@areas/workflows/mockExtensions' import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d' +import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker' +import { PickerIcon } from '@shared/components/ui' import ChatPanel from './ChatPanel' type PanelMode = 'basic' | 'chat' @@ -126,17 +128,17 @@ function ParamField({ param, value, onChange }: { ) } if (param.type === 'string') { + const intent = resolvePickerIntent(param) return (
onChange(e.target.value)} className={`${inputCls} flex-1`} />
) diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index a9703290..13363f4c 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -4,6 +4,8 @@ import { useExtensionsStore } from '@shared/stores/extensionsStore' import { buildAllWorkflowExtensions } from '../mockExtensions' import type { ParamSchema } from '../mockExtensions' import type { WFNodeData } from '@shared/types/electron.d' +import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker' +import { PickerIcon } from '@shared/components/ui' import { useWorkflowRunStore } from '../workflowRunStore' import BaseNode from './BaseNode' @@ -129,20 +131,21 @@ function ParamControl({ param, value, onChange, resolvedParams }: { ) } if (param.type === 'string') { + const intent = resolvePickerIntent(param) return (
onChange(e.target.value)} className={`${inputCls} flex-1`} />
) diff --git a/src/shared/components/ui/PickerIcon.tsx b/src/shared/components/ui/PickerIcon.tsx new file mode 100644 index 00000000..8893c3a0 --- /dev/null +++ b/src/shared/components/ui/PickerIcon.tsx @@ -0,0 +1,38 @@ +import type { PickerIntent } from '@shared/types/electron.d' + +/** + * Glyph for a param's browse button, matching the dialog it opens so the button + * advertises what it does (folder = the historical default). + */ +export function PickerIcon({ intent, size = 11 }: { intent: PickerIntent; size?: number }): JSX.Element { + const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 } + + if (intent === 'image') { + return ( + + + + + + ) + } + if (intent === 'mesh') { + return ( + + + + ) + } + if (intent === 'text') { + return ( + + + + ) + } + return ( + + + + ) +} diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index a31414a6..ef9d77f7 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -2,4 +2,5 @@ export { Tooltip } from './Tooltip' export { FieldLabel } from './FieldLabel' export { ConfirmModal } from './ConfirmModal' export { ColorPicker } from './ColorPicker' +export { PickerIcon } from './PickerIcon' export { Toast } from './Toast' diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 5be9ea27..d741a504 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -44,6 +44,8 @@ export interface ModelExtension { manifestError?: 'missing' | 'invalid' | 'incomplete' } +export type PickerIntent = 'folder' | 'image' | 'mesh' | 'text' + export interface ParamSchema { id: string label: string @@ -55,6 +57,10 @@ export interface ParamSchema { step?: number tooltip?: string show_if?: Record + // string: which native dialog the browse button opens (default: 'folder') + pickerIntent?: PickerIntent + /** snake_case alias of pickerIntent, for manifests that follow show_if/dir_from. */ + picker_intent?: PickerIntent // file-select: dropdown of the files inside the folder held by another param dir_from?: string // id of the (string) param holding the folder path extensions?: string[] // file extensions to list (e.g. ["json"]) diff --git a/src/shared/utils/paramPicker.test.mjs b/src/shared/utils/paramPicker.test.mjs new file mode 100644 index 00000000..5c975705 --- /dev/null +++ b/src/shared/utils/paramPicker.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// paramPicker.ts only type-imports from electron.d, so esbuild erases it. +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-parampicker-test-')), 'paramPicker.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/shared/utils/paramPicker.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +const { resolvePickerIntent, openParamPicker, PICKER_LABELS } = loadModule() + +/** Records which dialog was opened; each returns a path unique to that dialog. */ +function fakeFs() { + const calls = [] + return { + calls, + selectDirectory: () => { calls.push('selectDirectory'); return Promise.resolve('C:\\picked\\folder') }, + selectImage: () => { calls.push('selectImage'); return Promise.resolve('C:\\picked\\front.png') }, + selectMeshFile: () => { calls.push('selectMeshFile'); return Promise.resolve('C:\\picked\\model.glb') }, + selectTextFile: () => { calls.push('selectTextFile'); return Promise.resolve('C:\\picked\\notes.txt') }, + } +} + +const stringParam = (extra) => ({ id: 'front_image_path', label: 'Front image', type: 'string', default: '', ...extra }) + +// ─── resolvePickerIntent ─────────────────────────────────────────────────────── + +test('resolvePickerIntent honors pickerIntent and its snake_case alias', () => { + assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'image' })), 'image') + assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'mesh' })), 'mesh') + assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'text' })), 'text') + assert.equal(resolvePickerIntent(stringParam({ picker_intent: 'image' })), 'image') +}) + +test('resolvePickerIntent falls back to the folder picker when unset or unknown', () => { + assert.equal(resolvePickerIntent(stringParam()), 'folder') // pre-existing manifests + assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'folder' })), 'folder') + assert.equal(resolvePickerIntent(stringParam({ pickerIntent: 'hologram' })), 'folder') // newer manifest, older app + assert.equal(resolvePickerIntent(undefined), 'folder') +}) + +// ─── openParamPicker ─────────────────────────────────────────────────────────── + +test('openParamPicker opens the image dialog for pickerIntent: image — issue #155', async () => { + const fs = fakeFs() + const picked = await openParamPicker(stringParam({ pickerIntent: 'image' }), fs) + + assert.deepEqual(fs.calls, ['selectImage']) // and *not* selectDirectory + assert.equal(picked, 'C:\\picked\\front.png') +}) + +test('openParamPicker routes mesh/text intents to their existing dialogs', async () => { + const mesh = fakeFs() + assert.equal(await openParamPicker(stringParam({ pickerIntent: 'mesh' }), mesh), 'C:\\picked\\model.glb') + assert.deepEqual(mesh.calls, ['selectMeshFile']) + + const text = fakeFs() + assert.equal(await openParamPicker(stringParam({ picker_intent: 'text' }), text), 'C:\\picked\\notes.txt') + assert.deepEqual(text.calls, ['selectTextFile']) +}) + +test('openParamPicker keeps the folder dialog when no intent is declared', async () => { + const fs = fakeFs() + assert.equal(await openParamPicker(stringParam(), fs), 'C:\\picked\\folder') + assert.deepEqual(fs.calls, ['selectDirectory']) +}) + +test('PICKER_LABELS names every intent, so the browse button always has an accessible name', () => { + for (const intent of ['folder', 'image', 'mesh', 'text']) { + assert.equal(typeof PICKER_LABELS[intent], 'string') + assert.ok(PICKER_LABELS[intent].length > 0) + } +}) + +// ─── Call sites ──────────────────────────────────────────────────────────────── +// The bug in #155 was not in a resolver (there wasn't one) — it was the string +// param's browse button calling selectDirectory() unconditionally. There is no +// DOM harness in this repo, so guard the wiring at the source level instead. + +for (const file of [ + 'src/areas/workflows/nodes/ExtensionNode.tsx', + 'src/areas/generate/components/WorkflowPanel.tsx', +]) { + test(`${file} routes its string param browse button through openParamPicker`, () => { + const src = readFileSync(resolve(file), 'utf8') + assert.match(src, /openParamPicker\(param, window\.electron\.fs\)/) + assert.doesNotMatch(src, /const p = await window\.electron\.fs\.selectDirectory\(\)/) + }) +} diff --git a/src/shared/utils/paramPicker.ts b/src/shared/utils/paramPicker.ts new file mode 100644 index 00000000..7e3a1974 --- /dev/null +++ b/src/shared/utils/paramPicker.ts @@ -0,0 +1,45 @@ +import type { ParamSchema, PickerIntent } from '@shared/types/electron.d' + +// Which native dialog the browse button next to a `string` param opens. +// Extension manifests request one with `pickerIntent` (or `picker_intent`) on +// the param; params that don't set it keep the historical folder picker. + +export const PICKER_INTENTS = ['folder', 'image', 'mesh', 'text'] as const + +/** Accessible name / tooltip for the browse button, per intent. */ +export const PICKER_LABELS: Record = { + folder: 'Browse for a folder…', + image: 'Browse for an image file…', + mesh: 'Browse for a 3D mesh file…', + text: 'Browse for a text file…', +} + +/** Just the members of `window.electron.fs` a param picker can reach for. */ +export interface ParamPickerApi { + selectDirectory: (defaultPath?: string) => Promise + selectImage: () => Promise + selectMeshFile: () => Promise + selectTextFile: () => Promise +} + +type PickerParam = Pick + +/** + * Intent a param asks for, falling back to 'folder' — the behavior every + * `string` param had before `pickerIntent` existed — when it is unset or is a + * value this build doesn't know about. + */ +export function resolvePickerIntent(param: PickerParam | undefined): PickerIntent { + const requested = param?.pickerIntent ?? param?.picker_intent + return PICKER_INTENTS.includes(requested as PickerIntent) ? (requested as PickerIntent) : 'folder' +} + +/** Opens the dialog the param asked for. Resolves to null when cancelled. */ +export function openParamPicker(param: PickerParam | undefined, fs: ParamPickerApi): Promise { + switch (resolvePickerIntent(param)) { + case 'image': return fs.selectImage() + case 'mesh': return fs.selectMeshFile() + case 'text': return fs.selectTextFile() + default: return fs.selectDirectory() + } +}