dazscript-framework 1.0.41 → 1.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -256,11 +256,19 @@ export default defineConfig({
256
256
  defaultMenuPath: '/MyScripts',
257
257
  appDataPath: 'YourName/my-project', // required
258
258
  bundleName: 'My Project', // optional — used in the setup dialog title
259
+ assets: [
260
+ { from: './publishing/manual/User Guide.pdf', to: 'User Guide.pdf' },
261
+ { from: './licenses/NOTICE.txt', to: 'docs/NOTICE.txt' },
262
+ ],
259
263
  });
260
264
  ```
261
265
 
262
266
  `appDataPath` is required for builds that generate launcher shims and must be unique across your projects.
263
267
 
268
+ Each `assets` entry copies one completed file during the existing postbuild asset stage. `from` is relative to the project root; `to` is relative to `outDir`. Nested destination directories are created automatically. Mappings are explicit—directories and globs are not expanded—and every declared source file is required. A missing source fails the build with its configured path. Both paths must remain inside their respective roots.
269
+
270
+ Assets use the same stage as PNG icons, so `npm run build`, `npm run build:release`, and `npm run icons` copy them without product-specific scripts. The framework copies files only; it does not generate or convert PDFs.
271
+
264
272
  ---
265
273
 
266
274
  ### The `action(...)` Entrypoint
@@ -301,7 +309,7 @@ action({ text: 'My Script' }, MyScript);
301
309
  | `group` | Grouping label for related actions in Daz Studio |
302
310
  | `description` | Longer description for the action |
303
311
  | `icon` | Image path used for the installed custom action. Overrides discovered icon files. |
304
- | `bundle` | Generates a setup script beside the action. `true` → `Setup.dsa.ts`, a string → `Setup <name>.dsa.ts` |
312
+ | `bundle` | Generates a setup script beside the action. `true` → `<bundleName> Setup.dsa.ts`, a string → `<name> Setup.dsa.ts` |
305
313
 
306
314
  ---
307
315
 
@@ -334,7 +342,7 @@ Individual script packages can wrap this command in `package.json` scripts such
334
342
  npm run installer
335
343
  ```
336
344
 
337
- This scans all `.dsa.ts` entry files, reads each top-level `action(...)` call, and generates `src/Setup.dsa.ts` automatically. No installer code to maintain by hand.
345
+ This scans all `.dsa.ts` entry files, reads each top-level `action(...)` call, and generates `src/<bundleName> Setup.dsa.ts` automatically. Projects without a `bundleName` retain the legacy `src/Setup.dsa.ts` name. No installer code to maintain by hand.
338
346
 
339
347
  The generated setup dialog:
340
348
 
@@ -359,16 +367,16 @@ Custom action icons are selected from `action(...)` metadata and sibling image f
359
367
  3. `scriptname.png`
360
368
  4. `scriptname.dsa.png` legacy fallback
361
369
 
362
- `scriptname.action.png` is the installed custom action icon. Daz Studio uses the same action icon for menu and toolbar placements. `scriptname.png` is the preferred script/content icon fallback. `scriptname.dsa.png` is a legacy fallback kept for older projects and will be removed in a future breaking release.
370
+ `scriptname.action.png` is the installed custom action icon. Daz Studio uses the same action icon for menu and toolbar placements. `scriptname.png` is the preferred script/content icon fallback. `scriptname.dsa.png` remains supported as legacy source input, but builds copy and reference it as `scriptname.png`.
363
371
 
364
- Setup dialog header assets are optional and are discovered beside `src/Setup.dsa.ts`:
372
+ Setup dialog header assets are optional and are discovered in `src`:
365
373
 
366
374
  1. `src/Setup.header.png`
367
375
  2. `src/Setup.tip.png`
368
376
  3. `src/Setup.png`
369
377
  4. `src/Setup.dsa.png` legacy fallback
370
378
 
371
- Header text can be placed in `src/Setup.header.html`, `src/Setup.header.md`, or `src/Setup.header.txt`, with `src/Setup.html`, `src/Setup.md`, and `src/Setup.txt` as script-named fallbacks. The installer generator embeds that text into `Setup.dsa.ts`, so Daz Studio does not need to read the text file at setup time. The setup dialog renders the header body with `DzTextBrowser` rich text support; no Markdown conversion is performed. The image remains a deployed PNG asset and is resolved relative to the generated setup script at runtime.
379
+ Header text can be placed in `src/Setup.header.html`, `src/Setup.header.md`, or `src/Setup.header.txt`, with `src/Setup.html`, `src/Setup.md`, and `src/Setup.txt` as script-named fallbacks. The installer generator embeds that text into the generated setup script, so Daz Studio does not need to read the text file at setup time. The setup dialog renders the header body with `DzTextBrowser` rich text support; no Markdown conversion is performed. The image remains a deployed PNG asset and is resolved relative to the generated setup script at runtime.
372
380
 
373
381
  The same layout is available to custom dialogs through `add.header({ imagePath, html, text, height, imageWidth }).build()`. Use `html` for rich text, or `text` for escaped plain text.
374
382
 
@@ -398,7 +406,7 @@ The JSON can be an array or an object containing `actions`, `shortcuts`, or `acc
398
406
 
399
407
  Accepted shortcut fields are `shortcut`, `accelerator`, or `key`. Accepted action-name fields are `name` or `action`.
400
408
 
401
- At build time the JSON is embedded into generated `Setup.dsa.ts`; Daz Studio does not need to read the original JSON file at setup time. During setup, the `Keyboard Shortcuts` tab shows the action label, current shortcut, new shortcut, action type, and conflicts. The user chooses which shortcut rows to apply.
409
+ At build time the JSON is embedded into the generated setup script; Daz Studio does not need to read the original JSON file at setup time. During setup, the `Keyboard Shortcuts` tab shows the action label, current shortcut, new shortcut, action type, and conflicts. The user chooses which shortcut rows to apply.
402
410
 
403
411
  Before changing a non-custom Daz Studio action shortcut, setup writes the original value to:
404
412
 
@@ -416,8 +424,8 @@ The `bundle` property on `action(...)` is separate from the project-level `bundl
416
424
 
417
425
  When `bundle` is set, the installer generator also writes a setup script beside that action:
418
426
 
419
- - `bundle: true` → writes `Setup.dsa.ts`
420
- - `bundle: 'Utilities'` → writes `Setup Utilities.dsa.ts`
427
+ - `bundle: true` → writes `<bundleName> Setup.dsa.ts`, or `Setup.dsa.ts` when no project `bundleName` exists
428
+ - `bundle: 'Utilities'` → writes `Utilities Setup.dsa.ts`
421
429
 
422
430
  Those bundle-scoped setup files use the same setup dialog helper and also receive the project `bundleName`.
423
431
 
@@ -536,6 +544,7 @@ Use `this.add` inside `build()` to construct the UI declaratively.
536
544
  | `add.radio(text)` | Radio button |
537
545
  | `add.comboBox()` | Drop-down list |
538
546
  | `add.listBox()` | Scrollable list |
547
+ | `add.list.view<T>()` | Tree/list view; chain `.tabFocusWhenPopulated()` to skip empty or fully filtered lists during keyboard navigation |
539
548
  | `add.slider(min, max)` | Numeric slider |
540
549
  | `add.colorPicker()` | Color picker |
541
550
  | `add.nodeSelection()` | Daz Studio node selector |
@@ -635,7 +644,7 @@ The `test/unit/` files are generated only when you run `dazscript init --unit-te
635
644
  | `npm run build:release` | Build with `--log-level warn`, then encrypt implementation bundles |
636
645
  | `npm run watch` | Recompile on every save |
637
646
  | `npm run installer` | Generate the setup dialog |
638
- | `npm run icons` | Copy icon assets to the output folder |
647
+ | `npm run icons` | Copy PNG icons and configured assets to the output folder |
639
648
  | `npm test` | Run fast Node/Vitest tests |
640
649
  | `npm run test:integration` | Run a DAZ Studio headless integration fixture |
641
650
 
@@ -10,6 +10,27 @@ function copyFile(sourcePath, targetPath) {
10
10
  console.log(`copy ${path.relative(process.cwd(), targetPath)}`);
11
11
  }
12
12
 
13
+ function resolveAssetPath(rootPath, configuredPath, field) {
14
+ const resolvedRoot = path.resolve(rootPath);
15
+ const resolvedPath = path.resolve(resolvedRoot, configuredPath);
16
+ const relativePath = path.relative(resolvedRoot, resolvedPath);
17
+ if (path.isAbsolute(configuredPath) || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
18
+ throw new Error(`[dazscript assets] Asset ${field} path must stay inside its allowed root: ${configuredPath}`);
19
+ }
20
+ return resolvedPath;
21
+ }
22
+
23
+ function copyAssets(workdir, outDir, assets) {
24
+ (assets || []).forEach((asset) => {
25
+ const sourcePath = resolveAssetPath(workdir, asset.from, 'from');
26
+ const targetPath = resolveAssetPath(outDir, asset.to, 'to');
27
+ if (!fs.existsSync(sourcePath)) {
28
+ throw new Error(`[dazscript assets] Required asset not found: ${asset.from}`);
29
+ }
30
+ copyFile(sourcePath, targetPath);
31
+ });
32
+ }
33
+
13
34
  function copyIcons(workdir, options) {
14
35
  const sourceRoot = path.resolve(workdir, 'src');
15
36
  const outDir = path.resolve(workdir, options.outDir || './out');
@@ -18,8 +39,12 @@ function copyIcons(workdir, options) {
18
39
 
19
40
  files.forEach((filePath) => {
20
41
  const relativePath = path.relative(sourceRoot, filePath);
21
- copyFile(filePath, path.join(outDir, relativePath));
42
+ const outputRelativePath = relativePath.endsWith('.dsa.png')
43
+ ? `${relativePath.slice(0, -'.dsa.png'.length)}.png`
44
+ : relativePath;
45
+ copyFile(filePath, path.join(outDir, outputRelativePath));
22
46
  });
47
+ copyAssets(workdir, outDir, options.assets);
23
48
  }
24
49
 
25
50
  module.exports = {
@@ -37,6 +37,29 @@ function replaceEntryOutputSuffix(filePath, suffix) {
37
37
  return `${filePath}${suffix}`;
38
38
  }
39
39
 
40
+ function removeSetupArtifacts(workdir, outDir, scriptsPath, sourceFilePath) {
41
+ const absoluteSourcePath = path.resolve(workdir, sourceFilePath);
42
+ if (fs.existsSync(absoluteSourcePath)) {
43
+ fs.unlinkSync(absoluteSourcePath);
44
+ }
45
+
46
+ const relativeSourcePath = path.relative(
47
+ path.resolve(workdir, scriptsPath),
48
+ absoluteSourcePath
49
+ );
50
+ if (relativeSourcePath.startsWith('..') || path.isAbsolute(relativeSourcePath)) {
51
+ return;
52
+ }
53
+
54
+ const outputFilePath = path.join(
55
+ path.resolve(workdir, outDir),
56
+ replaceEntrySourceSuffix(relativeSourcePath, '.dsa')
57
+ );
58
+ if (fs.existsSync(outputFilePath)) {
59
+ fs.unlinkSync(outputFilePath);
60
+ }
61
+ }
62
+
40
63
  function getActionIconPath(filePath, decorator) {
41
64
  if (decorator.icon) {
42
65
  return decorator.icon;
@@ -64,9 +87,10 @@ function getActionIconPath(filePath, decorator) {
64
87
  : replaceEntryOutputSuffix(getPartialPath(filePath), '.png');
65
88
  }
66
89
 
67
- return isBundleAction
68
- ? path.parse(filePath).name.replace('.dsa', '.dsa.png')
69
- : `${getPartialPath(filePath)}.png`;
90
+ return replaceEntryOutputSuffix(
91
+ isBundleAction ? path.parse(filePath).name : getPartialPath(filePath),
92
+ '.png'
93
+ );
70
94
  }
71
95
 
72
96
  function generateInstallerTemplate(data, options) {
@@ -200,7 +224,7 @@ function findActionEntryFiles(workdir, options) {
200
224
  });
201
225
  }
202
226
 
203
- function processScript(filePath, container, defaultMenuPath, setupOptions) {
227
+ function processScript(workdir, filePath, container, defaultMenuPath, setupOptions, options) {
204
228
  const fileInfo = path.parse(filePath);
205
229
  const content = fs.readFileSync(filePath, 'utf-8').toString();
206
230
  const actionCall = findTopLevelActionCall(content, filePath);
@@ -251,11 +275,12 @@ function processScript(filePath, container, defaultMenuPath, setupOptions) {
251
275
  container.scripts.push(script);
252
276
 
253
277
  if (decorator.bundle !== undefined) {
254
- let packageSetupFilePath = `Setup.dsa.ts`;
255
-
256
- if (decorator.bundle !== true) {
257
- packageSetupFilePath = `Setup ${decorator.bundle}.dsa.ts`;
258
- }
278
+ const packageSetupFilePath = decorator.bundle === true
279
+ ? `${setupOptions.bundleName ? `${setupOptions.bundleName} ` : ''}Setup.dsa.ts`
280
+ : `${decorator.bundle} Setup.dsa.ts`;
281
+ const legacySetupFilePath = decorator.bundle === true
282
+ ? 'Setup.dsa.ts'
283
+ : `Setup ${decorator.bundle}.dsa.ts`;
259
284
 
260
285
  let bundleScriptContent = generateInstallerTemplate(
261
286
  JSON.stringify(container.scripts, null, 4),
@@ -265,14 +290,22 @@ function processScript(filePath, container, defaultMenuPath, setupOptions) {
265
290
  path.parse(filePath).dir,
266
291
  packageSetupFilePath
267
292
  );
293
+ if (legacySetupFilePath !== packageSetupFilePath) {
294
+ removeSetupArtifacts(
295
+ workdir,
296
+ options.outDir,
297
+ options.scriptsPath,
298
+ path.join(path.parse(filePath).dir, legacySetupFilePath)
299
+ );
300
+ }
268
301
  fs.writeFileSync(bundleScriptFilePath, bundleScriptContent);
269
302
  }
270
303
  }
271
304
 
272
- function processScripts(paths, container, defaultMenuPath, setupOptions) {
305
+ function processScripts(workdir, paths, container, defaultMenuPath, setupOptions, options) {
273
306
  paths.forEach((filePath) => {
274
307
  console.log(`Processing ${filePath}`);
275
- processScript(filePath, container, defaultMenuPath, setupOptions);
308
+ processScript(workdir, filePath, container, defaultMenuPath, setupOptions, options);
276
309
  });
277
310
  }
278
311
 
@@ -377,6 +410,10 @@ function generateInstallerFiles(workdir, options) {
377
410
  ? options.defaultMenuPath
378
411
  : `${options.defaultMenuPath}/`;
379
412
  const { config } = loadConfig(workdir);
413
+ const generatorOptions = {
414
+ scriptsPath: options.scriptsPath,
415
+ outDir: config.outDir || './out',
416
+ };
380
417
  const appDataPath = validateAppDataPath(options.appDataPath || config.appDataPath, workdir);
381
418
  const settingsPath = `${appDataPath}/Installer`;
382
419
  const bundleName = typeof config.bundleName === 'string' && config.bundleName.trim()
@@ -396,7 +433,7 @@ function generateInstallerFiles(workdir, options) {
396
433
  const container = { scripts: [] };
397
434
  const matches = findActionEntryFiles(workdir, options);
398
435
 
399
- processScripts(matches, container, defaultMenuPath, setupOptions);
436
+ processScripts(workdir, matches, container, defaultMenuPath, setupOptions, generatorOptions);
400
437
 
401
438
  container.scripts = container.scripts.sort((a, b) => {
402
439
  const aKey = a.menuPath + a.filePath;
@@ -408,7 +445,11 @@ function generateInstallerFiles(workdir, options) {
408
445
  JSON.stringify(container.scripts, null, 4),
409
446
  setupOptions
410
447
  );
411
- fs.writeFileSync(path.join(workdir, 'src', 'Setup.dsa.ts'), installerScriptContent);
448
+ const setupFileName = `${bundleName ? `${bundleName} ` : ''}Setup.dsa.ts`;
449
+ if (setupFileName !== 'Setup.dsa.ts') {
450
+ removeSetupArtifacts(workdir, generatorOptions.outDir, './src', path.join(workdir, 'src', 'Setup.dsa.ts'));
451
+ }
452
+ fs.writeFileSync(path.join(workdir, 'src', setupFileName), installerScriptContent);
412
453
 
413
454
  const installPath = path.join(workdir, 'src', 'Install.dsa.ts');
414
455
  const uninstallPath = path.join(workdir, 'src', 'Uninstall.dsa.ts');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dazscript-framework",
3
- "version": "1.0.41",
3
+ "version": "1.0.43",
4
4
  "author": "Freddy Diaz",
5
5
  "license": "MPL-2.0",
6
6
  "description": "",
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { Observable } from '@dsf/lib/observable'
3
3
  import { TreeNode } from '@dsf/lib/tree-node'
4
4
 
5
+ const buildEvents = vi.hoisted(() => [] as string[])
6
+
5
7
  const listView = vi.hoisted(() => ({
6
8
  items: [] as any[],
7
9
  addColumn: vi.fn(),
@@ -12,14 +14,17 @@ const listView = vi.hoisted(() => ({
12
14
  deleteItem: vi.fn(function (this: any, item: any) {
13
15
  this.items = this.items.filter((candidate: any) => candidate !== item)
14
16
  }),
15
- setSorting: vi.fn(),
17
+ setSorting: vi.fn((column: number) => buildEvents.push(`sorting:${column}`)),
16
18
  hide: vi.fn(),
17
19
  show: vi.fn(),
20
+ getWidget: vi.fn(() => focusWidget),
18
21
  selectedItem: vi.fn(() => null),
19
22
  doubleClicked: { scriptConnect: vi.fn() },
20
23
  contextMenuRequested: { scriptConnect: vi.fn() }
21
24
  }))
22
25
 
26
+ const focusWidget = vi.hoisted(() => ({ focusPolicy: 15 }))
27
+
23
28
  vi.mock('./widget-builder', () => ({
24
29
  createWidget: () => ({ build: () => listView })
25
30
  }))
@@ -35,9 +40,11 @@ class FakeListViewItem {
35
40
  data: any
36
41
  open = false
37
42
  selectable = true
43
+ visible = true
38
44
  textByColumn: Record<number, string> = {}
39
45
 
40
46
  constructor(parent: any, public id: number) {
47
+ buildEvents.push('item')
41
48
  parent.items.push(this)
42
49
  }
43
50
 
@@ -48,13 +55,30 @@ class FakeListViewItem {
48
55
 
49
56
  vi.stubGlobal('DzListView', { All: 0, Extended: 1 })
50
57
  vi.stubGlobal('DzListViewItem', FakeListViewItem)
58
+ vi.stubGlobal('QtFocusPolicy', { NoFocus: 0 })
51
59
 
52
- import { ListViewBuilder } from './list-view-builder'
60
+ import { filter } from '@dsf/helpers/list-view-helper'
61
+ import { ListViewBuilder, ListViewRefreshOptions } from './list-view-builder'
53
62
 
54
63
  describe('ListViewBuilder item updates', () => {
55
64
  beforeEach(() => {
56
65
  listView.items = []
57
66
  vi.clearAllMocks()
67
+ vi.mocked(filter).mockReset()
68
+ focusWidget.focusPolicy = 15
69
+ buildEvents.length = 0
70
+ })
71
+
72
+ it('suspends sorting while building rows and restores it afterward', () => {
73
+ new ListViewBuilder<any, any>({ dialog: {}, layout: null } as any)
74
+ .sorting(true)
75
+ .items(new Observable([new TreeNode('Action A', '', { name: 'ActionA' })]))
76
+ .columns(['Name'])
77
+ .text(item => [item.name])
78
+ .data(item => item.value)
79
+ .build()
80
+
81
+ expect(buildEvents).toEqual(['sorting:-1', 'item', 'sorting:0'])
58
82
  })
59
83
 
60
84
  it('keeps unchanged rows when data has no explicit id', () => {
@@ -75,4 +99,46 @@ describe('ListViewBuilder item updates', () => {
75
99
 
76
100
  expect(listView.items.map(item => item.textByColumn[0])).toEqual(['Action B'])
77
101
  })
102
+
103
+ it('removes empty lists from tab focus and restores populated lists', () => {
104
+ const items = new Observable<TreeNode<any>[]>([])
105
+
106
+ new ListViewBuilder<any, any>({ dialog: {}, layout: null } as any)
107
+ .tabFocusWhenPopulated()
108
+ .items(items)
109
+ .rebuildOnItemsChanged()
110
+ .columns(['Name'])
111
+ .text(item => [item.name])
112
+ .data(item => item.value)
113
+ .build()
114
+
115
+ expect(focusWidget.focusPolicy).toBe(0)
116
+
117
+ items.value = [new TreeNode('Action A', '', { name: 'ActionA' })]
118
+ expect(focusWidget.focusPolicy).toBe(15)
119
+
120
+ items.value = []
121
+ expect(focusWidget.focusPolicy).toBe(0)
122
+ })
123
+
124
+ it('removes fully filtered lists from tab focus', () => {
125
+ const refresh = new Observable<void>()
126
+
127
+ new ListViewBuilder<any, any>({ dialog: {}, layout: null } as any)
128
+ .tabFocusWhenPopulated()
129
+ .refresh(refresh, ListViewRefreshOptions.Filters)
130
+ .items(new Observable([new TreeNode('Action A', '', { name: 'ActionA' })]))
131
+ .filter({ keywords: new Observable(''), field: item => item.textByColumn[0] })
132
+ .columns(['Name'])
133
+ .text(item => [item.name])
134
+ .data(item => item.value)
135
+ .build()
136
+
137
+ vi.mocked(filter).mockImplementation((view: any) => {
138
+ view.items.forEach((item: any) => item.visible = false)
139
+ })
140
+ refresh.trigger()
141
+
142
+ expect(focusWidget.focusPolicy).toBe(0)
143
+ })
78
144
  })
@@ -51,6 +51,12 @@ export class ListViewBuilder<TItem, TData> implements IWidgetBuilder<DzListView>
51
51
  return this
52
52
  }
53
53
 
54
+ /** Removes the list from keyboard tab focus when it has no visible selectable rows. */
55
+ tabFocusWhenPopulated(): this {
56
+ this.context.tabFocusWhenPopulated = true
57
+ return this
58
+ }
59
+
54
60
  /**
55
61
  * Binds the list rows to a collection of items
56
62
  * @param items
@@ -156,6 +162,7 @@ class ListViewBuilderContext<TItem, TData> {
156
162
  flat: Observable<boolean>
157
163
  refreshWhat: ListViewRefreshOptions = ListViewRefreshOptions.All
158
164
  itemsChangeMode: ListViewItemsChangeMode = 'update'
165
+ tabFocusWhenPopulated: boolean = false
159
166
  constructor(public readonly dialogContext: WidgetBuilderContext) { }
160
167
  }
161
168
 
@@ -235,8 +242,17 @@ class ListViewBindBuilder<TItem, TData> {
235
242
 
236
243
  const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzListView => {
237
244
  const listView = createWidget(context.dialogContext).build(DzListView)
245
+ const focusWidget = context.tabFocusWhenPopulated ? listView.getWidget() : null
246
+ const populatedFocusPolicy = focusWidget?.focusPolicy
238
247
  let rowId = -1
239
248
 
249
+ const syncTabFocus = () => {
250
+ if (!focusWidget) return
251
+ const hasNavigableRow = listView.getItems(DzListView.All)
252
+ .some(item => item.visible !== false && item.selectable !== false)
253
+ focusWidget.focusPolicy = hasNavigableRow ? populatedFocusPolicy : QtFocusPolicy.NoFocus
254
+ }
255
+
240
256
  if (context.visible) {
241
257
  if (context.visible.value === false)
242
258
  listView.hide()
@@ -294,10 +310,11 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
294
310
 
295
311
  const filterList = (keywords?: string) => {
296
312
  filter(listView, context.filter.field, keywords ?? context.filter?.keywords?.value, { selectOnFilter: context.filter.selectOnFilter ?? true, filters: context.filter.filters })
313
+ syncTabFocus()
297
314
  }
298
315
  let delayedFilter: Delayed | null = null
299
316
 
300
- const buildList = (items: TreeNode<TItem>[], selectedId?: number) => {
317
+ const buildListRows = (items: TreeNode<TItem>[], selectedId?: number) => {
301
318
  rowId = -1
302
319
  context.decorated = false
303
320
  if (!context.text)
@@ -310,10 +327,15 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
310
327
  })
311
328
  listView.allColumnsShowFocus = true
312
329
 
313
- if (context.visible.value === false)
330
+ if (context.visible.value === false) {
331
+ syncTabFocus()
314
332
  return
333
+ }
315
334
 
316
- if (!items) return
335
+ if (!items) {
336
+ syncTabFocus()
337
+ return
338
+ }
317
339
 
318
340
  items.forEach((item) => {
319
341
  buildItem(item, listView)
@@ -334,6 +356,8 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
334
356
 
335
357
  if (context.filter?.keywords.value || context.filter?.filters)
336
358
  filterList()
359
+ else
360
+ syncTabFocus()
337
361
 
338
362
  if (selectedId) {
339
363
  listView.getItems(DzListView.All).forEach(item => {
@@ -346,6 +370,15 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
346
370
  }
347
371
  }
348
372
 
373
+ const buildList = (items: TreeNode<TItem>[], selectedId?: number) => {
374
+ listView.setSorting(-1, context.sortAscending)
375
+ try {
376
+ buildListRows(items, selectedId)
377
+ } finally {
378
+ listView.setSorting(context.sorting, context.sortAscending)
379
+ }
380
+ }
381
+
349
382
  const updateList = (items: TreeNode<TItem>[]) => {
350
383
  if (!context.text) return
351
384
  if (!items) return
@@ -400,6 +433,8 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
400
433
  if (!incomingPaths[path]) listView.deleteItem(li)
401
434
  })
402
435
 
436
+ syncTabFocus()
437
+
403
438
  }
404
439
 
405
440
  const onSelectionChanged = (callback: (item: DzListViewItem, data: TData) => void) => {
@@ -414,7 +449,6 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
414
449
  })
415
450
 
416
451
  listView.showSortIndicator = context.sorting >= 0
417
- listView.setSorting(context.sorting, context.sortAscending)
418
452
  if (context.selectionMode !== null) {
419
453
  listView.selectionMode = context.selectionMode
420
454
  }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, expect, it } from 'vitest'
5
+
6
+ const { copyIcons } = require('../../dist/scripts/icons')
7
+
8
+ const tempDirs: string[] = []
9
+
10
+ const makeProject = (): string => {
11
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsf-icons-'))
12
+ tempDirs.push(projectDir)
13
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true })
14
+ return projectDir
15
+ }
16
+
17
+ afterEach(() => {
18
+ while (tempDirs.length > 0) {
19
+ fs.rmSync(tempDirs.pop()!, { recursive: true, force: true })
20
+ }
21
+ })
22
+
23
+ it('copies a legacy dsa icon under the preferred output name', () => {
24
+ const projectDir = makeProject()
25
+ fs.writeFileSync(path.join(projectDir, 'src', 'Power Menu Favorite Actions.dsa.png'), 'legacy icon')
26
+
27
+ copyIcons(projectDir, { outDir: './out' })
28
+
29
+ expect(fs.readFileSync(path.join(projectDir, 'out', 'Power Menu Favorite Actions.png'), 'utf8')).toBe('legacy icon')
30
+ expect(fs.existsSync(path.join(projectDir, 'out', 'Power Menu Favorite Actions.dsa.png'))).toBe(false)
31
+ })
32
+
33
+ it('copies a declared asset from the project root into a nested output directory', () => {
34
+ const projectDir = makeProject()
35
+ fs.mkdirSync(path.join(projectDir, 'publishing', 'manual'), { recursive: true })
36
+ fs.writeFileSync(path.join(projectDir, 'publishing', 'manual', 'User Manual.pdf'), 'completed pdf')
37
+
38
+ copyIcons(projectDir, {
39
+ outDir: './build',
40
+ assets: [{ from: './publishing/manual/User Manual.pdf', to: 'docs/User Manual.pdf' }],
41
+ })
42
+
43
+ expect(fs.readFileSync(path.join(projectDir, 'build', 'docs', 'User Manual.pdf'), 'utf8')).toBe('completed pdf')
44
+ })
45
+
46
+ it('fails clearly when a declared asset is missing', () => {
47
+ const projectDir = makeProject()
48
+
49
+ expect(() => copyIcons(projectDir, {
50
+ outDir: './out',
51
+ assets: [{ from: './publishing/manual/User Manual.pdf', to: 'User Manual.pdf' }],
52
+ })).toThrow('[dazscript assets] Required asset not found: ./publishing/manual/User Manual.pdf')
53
+ })
54
+
55
+ it.each([
56
+ [{ from: '../User Manual.pdf', to: 'User Manual.pdf' }, 'from'],
57
+ [{ from: './User Manual.pdf', to: '../User Manual.pdf' }, 'to'],
58
+ ])('rejects an asset %s path outside its allowed root', (asset, field) => {
59
+ const projectDir = makeProject()
60
+
61
+ expect(() => copyIcons(projectDir, { outDir: './out', assets: [asset] }))
62
+ .toThrow(`[dazscript assets] Asset ${field} path must stay inside its allowed root`)
63
+ })
@@ -7,13 +7,13 @@ const { generateInstallerFiles } = require('../../dist/scripts/install-generator
7
7
 
8
8
  const tempDirs: string[] = []
9
9
 
10
- const makeProject = (): string => {
10
+ const makeProject = (bundleName?: string): string => {
11
11
  const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsf-install-generator-'))
12
12
  tempDirs.push(projectDir)
13
13
  fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true })
14
14
  fs.writeFileSync(
15
15
  path.join(projectDir, 'dazscript.config.cjs'),
16
- "module.exports = { appDataPath: 'Test/ActionIcons' }\n"
16
+ `module.exports = { appDataPath: 'Test/ActionIcons'${bundleName ? `, bundleName: '${bundleName}'` : ''} }\n`
17
17
  )
18
18
  return projectDir
19
19
  }
@@ -33,7 +33,7 @@ const writeText = (projectDir: string, fileName: string, content: string): void
33
33
  fs.writeFileSync(path.join(projectDir, 'src', fileName), content)
34
34
  }
35
35
 
36
- const generateSetup = (projectDir: string): string => {
36
+ const runGenerator = (projectDir: string): void => {
37
37
  const previousCwd = process.cwd()
38
38
  process.chdir(projectDir)
39
39
  try {
@@ -45,6 +45,10 @@ const generateSetup = (projectDir: string): string => {
45
45
  } finally {
46
46
  process.chdir(previousCwd)
47
47
  }
48
+ }
49
+
50
+ const generateSetup = (projectDir: string): string => {
51
+ runGenerator(projectDir)
48
52
  return fs.readFileSync(path.join(projectDir, 'src', 'Setup.dsa.ts'), 'utf8')
49
53
  }
50
54
 
@@ -55,6 +59,54 @@ afterEach(() => {
55
59
  }
56
60
  })
57
61
 
62
+ describe('install generator setup naming', () => {
63
+ it('emits only the product-prefixed project setup name', () => {
64
+ const projectDir = makeProject('Power Menu')
65
+ writeScript(projectDir, 'power-menu')
66
+ fs.mkdirSync(path.join(projectDir, 'out'), { recursive: true })
67
+ fs.writeFileSync(path.join(projectDir, 'src', 'Setup.dsa.ts'), 'legacy source')
68
+ fs.writeFileSync(path.join(projectDir, 'out', 'Setup.dsa'), 'legacy output')
69
+
70
+ runGenerator(projectDir)
71
+
72
+ expect(fs.existsSync(path.join(projectDir, 'src', 'Power Menu Setup.dsa.ts'))).toBe(true)
73
+ expect(fs.existsSync(path.join(projectDir, 'src', 'Setup.dsa.ts'))).toBe(false)
74
+ expect(fs.existsSync(path.join(projectDir, 'out', 'Setup.dsa'))).toBe(false)
75
+ })
76
+
77
+ it('emits only the prefix-first action bundle setup name', () => {
78
+ const projectDir = makeProject('Power Menu')
79
+ writeScript(projectDir, 'utilities', ", bundle: 'Utilities'")
80
+ fs.mkdirSync(path.join(projectDir, 'out'), { recursive: true })
81
+ fs.writeFileSync(path.join(projectDir, 'src', 'Setup Utilities.dsa.ts'), 'legacy source')
82
+ fs.writeFileSync(path.join(projectDir, 'out', 'Setup Utilities.dsa'), 'legacy output')
83
+
84
+ runGenerator(projectDir)
85
+
86
+ expect(fs.existsSync(path.join(projectDir, 'src', 'Utilities Setup.dsa.ts'))).toBe(true)
87
+ expect(fs.existsSync(path.join(projectDir, 'src', 'Setup Utilities.dsa.ts'))).toBe(false)
88
+ expect(fs.existsSync(path.join(projectDir, 'out', 'Setup Utilities.dsa'))).toBe(false)
89
+ })
90
+
91
+ it('uses the project prefix for an unnamed action bundle setup', () => {
92
+ const projectDir = makeProject('Power Menu')
93
+ fs.mkdirSync(path.join(projectDir, 'src', 'tools'), { recursive: true })
94
+ fs.mkdirSync(path.join(projectDir, 'out', 'tools'), { recursive: true })
95
+ fs.writeFileSync(
96
+ path.join(projectDir, 'src', 'tools', 'utilities.dsa.ts'),
97
+ "action({ text: 'utilities', bundle: true }, function() {})\n"
98
+ )
99
+ fs.writeFileSync(path.join(projectDir, 'src', 'tools', 'Setup.dsa.ts'), 'legacy source')
100
+ fs.writeFileSync(path.join(projectDir, 'out', 'tools', 'Setup.dsa'), 'legacy output')
101
+
102
+ runGenerator(projectDir)
103
+
104
+ expect(fs.existsSync(path.join(projectDir, 'src', 'tools', 'Power Menu Setup.dsa.ts'))).toBe(true)
105
+ expect(fs.existsSync(path.join(projectDir, 'src', 'tools', 'Setup.dsa.ts'))).toBe(false)
106
+ expect(fs.existsSync(path.join(projectDir, 'out', 'tools', 'Setup.dsa'))).toBe(false)
107
+ })
108
+ })
109
+
58
110
  describe('install generator action icons', () => {
59
111
  it('prefers the action icon convention over the script icon fallback', () => {
60
112
  const projectDir = makeProject()
@@ -82,14 +134,15 @@ describe('install generator action icons', () => {
82
134
  expect(setup).not.toContain('"icon": "./power-menu.dsa.png"')
83
135
  })
84
136
 
85
- it('keeps the dsa-named script icon as a legacy fallback', () => {
137
+ it('normalizes the dsa-named legacy icon in generated setup', () => {
86
138
  const projectDir = makeProject()
87
139
  writeScript(projectDir, 'legacy-icon')
88
140
  writePng(projectDir, 'legacy-icon.dsa.png')
89
141
 
90
142
  const setup = generateSetup(projectDir)
91
143
 
92
- expect(setup).toContain('"icon": "./legacy-icon.dsa.png"')
144
+ expect(setup).toContain('"icon": "./legacy-icon.png"')
145
+ expect(setup).not.toContain('"icon": "./legacy-icon.dsa.png"')
93
146
  })
94
147
 
95
148
  it('lets explicit action icon metadata override discovered icon files', () => {
File without changes