Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/areas/generate/components/WorkflowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -126,17 +128,17 @@ function ParamField({ param, value, onChange }: {
)
}
if (param.type === 'string') {
const intent = resolvePickerIntent(param)
return (
<div className="flex items-center gap-1">
<input type="text" value={value as string} placeholder={param.tooltip ?? ''}
onChange={(e) => onChange(e.target.value)} className={`${inputCls} flex-1`} />
<button onClick={async () => {
const p = await window.electron.fs.selectDirectory()
const p = await openParamPicker(param, window.electron.fs)
if (p) onChange(p)
}} className="shrink-0 flex items-center justify-center w-6 h-6 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-200 transition-colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
}} title={PICKER_LABELS[intent]} aria-label={PICKER_LABELS[intent]}
className="shrink-0 flex items-center justify-center w-6 h-6 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-200 transition-colors">
<PickerIcon intent={intent} />
</button>
</div>
)
Expand Down
11 changes: 7 additions & 4 deletions src/areas/workflows/nodes/ExtensionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -129,20 +131,21 @@ function ParamControl({ param, value, onChange, resolvedParams }: {
)
}
if (param.type === 'string') {
const intent = resolvePickerIntent(param)
return (
<div className="flex items-center gap-1">
<input type="text" value={value as string} placeholder={param.tooltip ?? ''}
onChange={(e) => onChange(e.target.value)} className={`${inputCls} flex-1`} />
<button
onClick={async () => {
const p = await window.electron.fs.selectDirectory()
const p = await openParamPicker(param, window.electron.fs)
if (p) onChange(p)
}}
title={PICKER_LABELS[intent]}
aria-label={PICKER_LABELS[intent]}
className="nodrag shrink-0 flex items-center justify-center w-6 h-6 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-200 transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
<PickerIcon intent={intent} />
</button>
</div>
)
Expand Down
38 changes: 38 additions & 0 deletions src/shared/components/ui/PickerIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg {...common}>
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
)
}
if (intent === 'mesh') {
return (
<svg {...common}>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
)
}
if (intent === 'text') {
return (
<svg {...common}>
<path d="M17 6.1H3M21 12.1H3M15.1 18H3"/>
</svg>
)
}
return (
<svg {...common}>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
)
}
1 change: 1 addition & 0 deletions src/shared/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 6 additions & 0 deletions src/shared/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,6 +57,10 @@ export interface ParamSchema {
step?: number
tooltip?: string
show_if?: Record<string, string | number | (string | number)[]>
// 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"])
Expand Down
103 changes: 103 additions & 0 deletions src/shared/utils/paramPicker.test.mjs
Original file line number Diff line number Diff line change
@@ -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\(\)/)
})
}
45 changes: 45 additions & 0 deletions src/shared/utils/paramPicker.ts
Original file line number Diff line number Diff line change
@@ -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<PickerIntent, string> = {
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<string | null>
selectImage: () => Promise<string | null>
selectMeshFile: () => Promise<string | null>
selectTextFile: () => Promise<string | null>
}

type PickerParam = Pick<ParamSchema, 'pickerIntent' | 'picker_intent'>

/**
* 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<string | null> {
switch (resolvePickerIntent(param)) {
case 'image': return fs.selectImage()
case 'mesh': return fs.selectMeshFile()
case 'text': return fs.selectTextFile()
default: return fs.selectDirectory()
}
}