dazscript-framework 1.0.7 → 1.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dazscript-framework",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "author": "Freddy Diaz",
5
5
  "license": "MPL-2.0",
6
6
  "description": "",
@@ -1,58 +1,11 @@
1
1
  import { debug, warn } from '@dsf/common/log'
2
2
  import { mainWindow } from '@dsf/core/global'
3
3
  import { isGUID } from './string-helper'
4
+ export { normalizeShortcut } from './shortcut-helper'
5
+ import { normalizeShortcut } from './shortcut-helper'
4
6
 
5
7
  const actionMgr = mainWindow.getActionMgr()
6
8
 
7
- const shortcutTokenMap: { [key: string]: string } = {
8
- 'CTRL': 'Ctrl',
9
- 'CONTROL': 'Ctrl',
10
- 'SHIFT': 'Shift',
11
- 'ALT': 'Alt',
12
- 'OPTION': 'Alt',
13
- 'WIN': 'Win',
14
- 'WINDOWS': 'Win',
15
- 'CMD': 'Win',
16
- 'COMMAND': 'Win',
17
- 'SPACE': 'Space',
18
- 'HOME': 'Home',
19
- 'END': 'End',
20
- 'INS': 'Ins',
21
- 'INSERT': 'Ins',
22
- 'DEL': 'Del',
23
- 'DELETE': 'Del',
24
- 'TAB': 'Tab',
25
- 'BACKSPACE': 'Backspace',
26
- 'COMMA': 'Comma',
27
- 'PERIOD': 'Period',
28
- 'PLUS': 'Plus',
29
- 'MINUS': 'Minus',
30
- 'PGUP': 'PgUp',
31
- 'PAGEUP': 'PgUp',
32
- 'PGDOWN': 'PgDn',
33
- 'PGDN': 'PgDn',
34
- 'LEFT': 'Left',
35
- 'RIGHT': 'Right',
36
- 'UP': 'Up',
37
- 'DOWN': 'Down',
38
- }
39
-
40
- export const normalizeShortcut = (shortcut: string): string => {
41
- if (!shortcut) return ''
42
-
43
- return shortcut
44
- .split('+')
45
- .map(part => part.trim())
46
- .filter(Boolean)
47
- .map(part => {
48
- if (part.length === 1) return part.toUpperCase()
49
-
50
- const key = part.toUpperCase()
51
- return shortcutTokenMap[key] || part
52
- })
53
- .join('+')
54
- }
55
-
56
9
  export const getActionShortcut = (name: string): string => {
57
10
  const action = findAction(name)
58
11
  if (!action) return ''
@@ -184,7 +137,7 @@ export const findActionsForShortcut = (shortcut: string): DzAction[] => {
184
137
  return result
185
138
  }
186
139
 
187
- export const clearActionShorcut = (name: string) => {
140
+ export const clearActionShortcut = (name: string) => {
188
141
  let action = findAction(name)
189
142
  if (!action) return
190
143
  if (isCustomAction(action)) {
@@ -195,6 +148,8 @@ export const clearActionShorcut = (name: string) => {
195
148
  }
196
149
  }
197
150
 
151
+ export const clearActionShorcut = clearActionShortcut
152
+
198
153
  export const getActionPixmap = (action: string, icon: string, maxSize?: number): Pixmap | null => {
199
154
  try {
200
155
  if (!action) return null;
@@ -280,6 +280,12 @@ const removeUnderlyingCustomAction = (actionName: string | null | undefined) =>
280
280
  const index = actionMgr.findCustomAction(actionName)
281
281
  if (index < 0) return
282
282
 
283
+ const shortcut = String(actionMgr.getCustomActionShortcut(index) ?? '').trim()
284
+ if (shortcut) {
285
+ actionMgr.setCustomActionShortcut(index, '')
286
+ debug(`Action ${actionName} shortcut cleared before removal`)
287
+ }
288
+
283
289
  actionMgr.removeCustomAction(index)
284
290
  debug(`Action ${actionName} removed`)
285
291
  }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getCanonicalInstallerEntry, toActionKey } from './custom-action-installer-entries'
3
+
4
+ const entries = [
5
+ { action: { text: 'Power Menu - Main Actions', filePath: './Power Menu Actions.dsa' }, effectiveShortcut: '' },
6
+ { action: { text: 'Power Menu', filePath: './Power Menu.dsa' }, effectiveShortcut: '' },
7
+ ]
8
+
9
+ describe('setup installer entries', () => {
10
+ it('builds an empty key for missing action data instead of throwing', () => {
11
+ expect(toActionKey(undefined)).toBe('')
12
+ })
13
+
14
+ it('resolves row data back to the canonical installer entry', () => {
15
+ const rowCopy = {
16
+ action: { text: 'Power Menu', filePath: './Power Menu.dsa' },
17
+ effectiveShortcut: 'F12'
18
+ }
19
+
20
+ const canonical = getCanonicalInstallerEntry(entries, rowCopy)
21
+
22
+ expect(canonical).toBe(entries[1])
23
+ })
24
+
25
+ it('falls back to the displayed action text when row data is not an installer entry', () => {
26
+ const canonical = getCanonicalInstallerEntry(entries, { value: null }, 'Power Menu')
27
+
28
+ expect(canonical).toBe(entries[1])
29
+ })
30
+
31
+ it('ignores row data with an invalid action property before falling back to displayed text', () => {
32
+ const canonical = getCanonicalInstallerEntry(entries, { action: true }, 'Power Menu')
33
+
34
+ expect(canonical).toBe(entries[1])
35
+ })
36
+
37
+ it('prefers displayed row text over attached row data', () => {
38
+ const staleRowData = {
39
+ action: { text: 'Power Menu - Main Actions', filePath: './Power Menu Actions.dsa' },
40
+ effectiveShortcut: ''
41
+ }
42
+
43
+ const canonical = getCanonicalInstallerEntry(entries, staleRowData, 'Power Menu')
44
+
45
+ expect(canonical).toBe(entries[1])
46
+ })
47
+
48
+ it('returns null when neither row data nor displayed text identify an installer entry', () => {
49
+ const canonical = getCanonicalInstallerEntry(entries, { action: true }, 'Missing')
50
+
51
+ expect(canonical).toBeNull()
52
+ })
53
+ })
@@ -0,0 +1,58 @@
1
+ import type { CustomAction } from '@dsf/core/custom-action'
2
+
3
+ export type InstallerActionEntry = {
4
+ action: CustomAction
5
+ }
6
+
7
+ const isActionLike = (value: any): value is CustomAction =>
8
+ Boolean(value && (typeof value.filePath !== 'undefined' || typeof value.text !== 'undefined'))
9
+
10
+ export const toActionKey = (action?: CustomAction | null): string =>
11
+ String(action?.filePath ?? action?.text ?? '')
12
+
13
+ const findByDisplayedText = <TEntry extends InstallerActionEntry>(
14
+ entries: TEntry[],
15
+ displayedText: string
16
+ ): TEntry | null => {
17
+ for (let i = 0; i < entries.length; i++) {
18
+ const entry = entries[i]
19
+ if (String(entry.action.text ?? '') === displayedText) return entry
20
+ }
21
+
22
+ return null
23
+ }
24
+
25
+ const findByActionKey = <TEntry extends InstallerActionEntry>(
26
+ entries: TEntry[],
27
+ key: string
28
+ ): TEntry | null => {
29
+ for (let i = 0; i < entries.length; i++) {
30
+ const entry = entries[i]
31
+ if (toActionKey(entry.action) === key) return entry
32
+ }
33
+
34
+ return null
35
+ }
36
+
37
+ export const asInstallerActionEntry = <TEntry extends InstallerActionEntry>(value: any): TEntry | null => {
38
+ if (isActionLike(value?.action)) return value as TEntry
39
+ if (isActionLike(value?.value?.action)) return value.value as TEntry
40
+ return null
41
+ }
42
+
43
+ export const getCanonicalInstallerEntry = <TEntry extends InstallerActionEntry>(
44
+ entries: TEntry[],
45
+ value: any,
46
+ displayedActionText?: string
47
+ ): TEntry | null => {
48
+ const displayedText = String(displayedActionText ?? '')
49
+ const displayedMatch = findByDisplayedText(entries, displayedText)
50
+ if (displayedMatch) return displayedMatch
51
+
52
+ const candidate = asInstallerActionEntry<TEntry>(value)
53
+
54
+ if (!candidate || !isActionLike(candidate.action)) return null
55
+
56
+ const key = toActionKey(candidate.action)
57
+ return findByActionKey(entries, key) ?? candidate
58
+ }
@@ -11,6 +11,8 @@ import CustomSet from '@dsf/lib/set'
11
11
  import { TreeNode } from '@dsf/lib/tree-node'
12
12
  import { promptKeyboardShortcut } from '@dsf/shared/set-keyboard-shortcut'
13
13
  import { readFromFile, saveToFile } from './file-helper'
14
+ import { getCanonicalInstallerEntry, toActionKey } from './custom-action-installer-entries'
15
+ import { canResetSetupShortcut, getDisplayedSetupShortcut, resetSetupShortcut, setSetupShortcut, updateShortcutOverrideState } from './custom-action-installer-shortcuts'
14
16
 
15
17
  type InstallerEntry = {
16
18
  action: CustomAction
@@ -70,9 +72,7 @@ type SetupSelection = {
70
72
  shortcuts: ShortcutEntry[]
71
73
  }
72
74
 
73
- const OVERRIDE_MARKER = '[ovr]'
74
-
75
- const toKey = (action: CustomAction): string => String(action.filePath ?? action.text ?? '')
75
+ const toKey = toActionKey
76
76
 
77
77
  const toTreeNode = (entry: InstallerEntry): TreeNode<InstallerEntry> =>
78
78
  new TreeNode(String(entry.action.text), toKey(entry.action), entry)
@@ -96,18 +96,6 @@ const getSetupDialogOptions = (options: string | SetupDialogOptions): SetupDialo
96
96
  const getShortcutBackupPath = (options: SetupDialogOptions): string =>
97
97
  `${App.getAppDataPath()}/${options.shortcutBackupPath ?? `${options.settingsPath}/keyboard-shortcuts-backup.json`}`
98
98
 
99
- const getDisplayedShortcut = (entry: InstallerEntry): string => {
100
- if (!entry.isShortcutOverridden) return entry.effectiveShortcut
101
- return entry.effectiveShortcut
102
- ? `${entry.effectiveShortcut} ${OVERRIDE_MARKER}`
103
- : OVERRIDE_MARKER
104
- }
105
-
106
- const updateOverrideState = (entry: InstallerEntry) => {
107
- entry.isShortcutOverridden = Boolean(entry.installedActionName) &&
108
- normalizeShortcut(entry.installedShortcut) !== normalizeShortcut(entry.defaultShortcut)
109
- }
110
-
111
99
  const buildEntries = (actions: CustomAction[]): InstallerEntry[] => {
112
100
  return actions
113
101
  .filter((action) => Boolean(action.menuPath) || Boolean(action.toolbar))
@@ -132,7 +120,7 @@ const buildEntries = (actions: CustomAction[]): InstallerEntry[] => {
132
120
  installedActionName: String(installed.customAction?.name ?? '')
133
121
  }
134
122
 
135
- updateOverrideState(entry)
123
+ updateShortcutOverrideState(entry)
136
124
  return entry
137
125
  })
138
126
  }
@@ -235,8 +223,7 @@ class InstallerSelectionDialog extends BasicDialog {
235
223
  .focus()
236
224
  .placeholder('Search actions...')
237
225
  .toolTip('Search by action name, description, path, shortcut, or available targets.')
238
- add.button('Select All').clicked(() => this.setVisibleSelections(true))
239
- add.button('Deselect All').clicked(() => this.setVisibleSelections(false))
226
+ add.button('Clear').clicked(() => this.keywords$.value = '')
240
227
  })
241
228
 
242
229
  add.group('Scripts')
@@ -269,7 +256,7 @@ class InstallerSelectionDialog extends BasicDialog {
269
256
  return [
270
257
  '',
271
258
  String(entry.action.text ?? ''),
272
- getDisplayedShortcut(entry),
259
+ getDisplayedSetupShortcut(entry),
273
260
  String(entry.action.description ?? ''),
274
261
  String(entry.action.menuPath ?? ''),
275
262
  getDisplayedToolbar(entry),
@@ -291,6 +278,11 @@ class InstallerSelectionDialog extends BasicDialog {
291
278
  listView.allColumnsShowFocus = true
292
279
  })
293
280
  })
281
+ add.group().horizontal().build((layout) => {
282
+ layout.addStretch()
283
+ add.button('Select All').clicked(() => this.setVisibleSelections(true))
284
+ add.button('Deselect All').clicked(() => this.setVisibleSelections(false))
285
+ })
294
286
  }
295
287
 
296
288
  private buildShortcutsTab(): void {
@@ -304,8 +296,7 @@ class InstallerSelectionDialog extends BasicDialog {
304
296
  .value(this.shortcutKeywords$)
305
297
  .placeholder('Search shortcuts...')
306
298
  .toolTip('Search by action name, current shortcut, new shortcut, status, or conflicts.')
307
- add.button('Select All').clicked(() => this.setVisibleShortcutSelections(true))
308
- add.button('Deselect All').clicked(() => this.setVisibleShortcutSelections(false))
299
+ add.button('Clear').clicked(() => this.shortcutKeywords$.value = '')
309
300
  })
310
301
 
311
302
  add.group('Keyboard Shortcuts')
@@ -358,12 +349,20 @@ class InstallerSelectionDialog extends BasicDialog {
358
349
  listView.allColumnsShowFocus = true
359
350
  })
360
351
  })
352
+ add.group().horizontal().build((layout) => {
353
+ layout.addStretch()
354
+ add.button('Select All').clicked(() => this.setVisibleShortcutSelections(true))
355
+ add.button('Deselect All').clicked(() => this.setVisibleShortcutSelections(false))
356
+ })
361
357
  }
362
358
 
363
359
  private buildContextMenu(listView: DzListView, listItem: DzListViewItem | null): DzPopupMenu {
364
- const entry = listItem ? getDataItem<InstallerEntry>(listItem) : null
360
+ debug(`[Setup] context menu row input ${this.describeListItem('item', listItem)} ${this.describeListItem('selected', listView.selectedItem())} ${this.describeListItem('current', listView.currentItem())}`)
361
+ const contextItem = this.getContextListItem(listView, listItem)
362
+ const entry = contextItem ? this.getCanonicalEntry(getDataItem<InstallerEntry>(contextItem), contextItem) : null
363
+ debug(`[Setup] context menu row resolved item="${this.safeListItemText(contextItem, 1)}" entry="${entry?.action?.text ?? ''}"`)
365
364
  const canSetShortcut = Boolean(entry)
366
- const canResetShortcut = Boolean(entry?.installedActionName && entry?.isShortcutOverridden)
365
+ const canResetShortcut = Boolean(entry && canResetSetupShortcut(entry))
367
366
 
368
367
  const items = [
369
368
  {
@@ -391,31 +390,77 @@ class InstallerSelectionDialog extends BasicDialog {
391
390
  })
392
391
  }
393
392
 
393
+ private getContextListItem(listView: DzListView, listItem: any): DzListViewItem | null {
394
+ if (this.isListViewItem(listItem)) return listItem
395
+
396
+ const selectedItem = listView.selectedItem()
397
+ if (this.isListViewItem(selectedItem)) return selectedItem
398
+
399
+ const currentItem = listView.currentItem()
400
+ if (this.isListViewItem(currentItem)) return currentItem
401
+
402
+ return null
403
+ }
404
+
405
+ private isListViewItem(value: any): value is DzListViewItem {
406
+ return Boolean(value && typeof value.text === 'function')
407
+ }
408
+
409
+ private describeListItem(label: string, value: any): string {
410
+ if (!value) return `${label}=null`
411
+
412
+ const hasTextFunction = typeof value.text === 'function'
413
+ const text0 = this.safeListItemText(value, 0)
414
+ const text1 = this.safeListItemText(value, 1)
415
+ const text2 = this.safeListItemText(value, 2)
416
+ const hasDataFunction = typeof value.getDataItem === 'function'
417
+ let dataText = ''
418
+
419
+ if (hasDataFunction) {
420
+ const data = getDataItem<any>(value)
421
+ dataText = String(data?.action?.text ?? data?.value?.action?.text ?? '')
422
+ }
423
+
424
+ return `${label} type=${typeof value} textFn=${hasTextFunction} text0="${text0}" text1="${text1}" text2="${text2}" dataFn=${hasDataFunction} data="${dataText}"`
425
+ }
426
+
427
+ private safeListItemText(value: any, column: number): string {
428
+ try {
429
+ if (!value || typeof value.text !== 'function') return ''
430
+ return String(value.text(column) ?? '')
431
+ } catch (error) {
432
+ return `[error:${String(error)}]`
433
+ }
434
+ }
435
+
436
+ private getCanonicalEntry(entry: InstallerEntry | null, listItem?: DzListViewItem | null): InstallerEntry | null {
437
+ return getCanonicalInstallerEntry(this.entries, entry, listItem?.text(1))
438
+ }
439
+
394
440
  private setShortcut(entry: InstallerEntry) {
441
+ if (!entry?.action) {
442
+ debug('[Setup] shortcut set ignored because the selected row did not resolve to an installer entry')
443
+ return
444
+ }
445
+
395
446
  const actionName = entry.installedActionName || toKey(entry.action)
396
447
  const actionLabel = String(entry.action.text ?? entry.action.filePath ?? actionName)
397
448
  const shortcut = promptKeyboardShortcut(actionLabel, actionName, entry.effectiveShortcut)
398
449
  if (shortcut == null) return
399
450
 
400
- if (entry.installedActionName) {
401
- setActionShortcut(entry.installedActionName, shortcut)
402
- entry.installedShortcut = normalizeShortcut(shortcut)
403
- entry.effectiveShortcut = entry.installedShortcut
404
- updateOverrideState(entry)
405
- } else {
406
- entry.effectiveShortcut = normalizeShortcut(shortcut)
407
- }
451
+ const previousShortcut = entry.effectiveShortcut
452
+ setSetupShortcut(entry, shortcut)
453
+ debug(`[Setup] shortcut queued action="${entry.action.text}" installed=${Boolean(entry.installedActionName)} previous="${previousShortcut}" next="${entry.effectiveShortcut}" custom=${entry.isShortcutOverridden}`)
408
454
 
409
455
  this.refreshListEvent$.trigger()
410
456
  }
411
457
 
412
458
  private resetDefaultShortcut(entry: InstallerEntry) {
413
- if (!entry.installedActionName || !entry.isShortcutOverridden) return
459
+ if (!canResetSetupShortcut(entry)) return
414
460
 
415
- setActionShortcut(entry.installedActionName, entry.defaultShortcut)
416
- entry.installedShortcut = normalizeShortcut(entry.defaultShortcut)
417
- entry.effectiveShortcut = entry.installedShortcut
418
- updateOverrideState(entry)
461
+ const previousShortcut = entry.effectiveShortcut
462
+ resetSetupShortcut(entry)
463
+ debug(`[Setup] shortcut reset queued action="${entry.action.text}" previous="${previousShortcut}" default="${entry.defaultShortcut}"`)
419
464
  this.refreshListEvent$.trigger()
420
465
  }
421
466
 
@@ -565,7 +610,7 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
565
610
  const selectedToolbarActions: CustomAction[] = []
566
611
 
567
612
  progress('Setting Up Scripts', selections.actions, (selection) => {
568
- debug(`[Setup] ${selection.selected ? 'install' : 'remove'} "${selection.action.text}"`)
613
+ debug(`[Setup] ${selection.selected ? 'install' : 'remove'} "${selection.action.text}" shortcut="${selection.effectiveShortcut}" custom=${selection.isShortcutOverridden}`)
569
614
 
570
615
  if (selection.supportsToolbar && selection.action.toolbar) {
571
616
  touchedToolbarNames.add(selection.action.toolbar)
@@ -574,7 +619,7 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
574
619
  if (selection.selected) {
575
620
  const customAction = applyCustomActionTargets({
576
621
  ...selection.action,
577
- shortcut: selection.effectiveShortcut
622
+ shortcut: ''
578
623
  }, {
579
624
  menu: selection.supportsMenu,
580
625
  toolbar: selection.supportsToolbar
@@ -582,6 +627,12 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
582
627
  deferToolbar: true
583
628
  })
584
629
 
630
+ if (customAction) {
631
+ setActionShortcut(String(customAction.name), selection.effectiveShortcut)
632
+ const appliedShortcut = getActionShortcut(String(customAction.name))
633
+ debug(`[Setup] applied shortcut "${appliedShortcut}" to "${selection.action.text}" after install`)
634
+ }
635
+
585
636
  if (customAction && selection.supportsToolbar && selection.action.toolbar) {
586
637
  selectedToolbarActions.push(customAction)
587
638
  }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ canResetSetupShortcut,
4
+ getDisplayedSetupShortcut,
5
+ resetSetupShortcut,
6
+ setSetupShortcut,
7
+ updateShortcutOverrideState
8
+ } from './custom-action-installer-shortcuts'
9
+
10
+ const shortcutState = (overrides: Partial<ReturnType<typeof baseState>> = {}) => ({
11
+ ...baseState(),
12
+ ...overrides
13
+ })
14
+
15
+ const baseState = () => ({
16
+ installedActionName: '',
17
+ installedShortcut: '',
18
+ defaultShortcut: '',
19
+ effectiveShortcut: '',
20
+ isShortcutOverridden: false
21
+ })
22
+
23
+ describe('setup shortcut state', () => {
24
+ it('stores pending shortcuts for actions that are not installed yet', () => {
25
+ const entry = shortcutState()
26
+
27
+ setSetupShortcut(entry, 'F12')
28
+
29
+ expect(entry.effectiveShortcut).toBe('F12')
30
+ expect(entry.installedShortcut).toBe('')
31
+ expect(entry.isShortcutOverridden).toBe(false)
32
+ expect(canResetSetupShortcut(entry)).toBe(true)
33
+ })
34
+
35
+ it('marks installed shortcuts that differ from the default as custom', () => {
36
+ const entry = shortcutState({
37
+ installedActionName: 'installed-guid',
38
+ defaultShortcut: '',
39
+ installedShortcut: ''
40
+ })
41
+
42
+ setSetupShortcut(entry, 'F12')
43
+
44
+ expect(entry.installedShortcut).toBe('F12')
45
+ expect(entry.effectiveShortcut).toBe('F12')
46
+ expect(entry.isShortcutOverridden).toBe(true)
47
+ expect(getDisplayedSetupShortcut(entry)).toBe('F12 [custom]')
48
+ expect(canResetSetupShortcut(entry)).toBe(true)
49
+ })
50
+
51
+ it('resets installed custom shortcuts to the default', () => {
52
+ const entry = shortcutState({
53
+ installedActionName: 'installed-guid',
54
+ defaultShortcut: 'Ctrl+P',
55
+ installedShortcut: 'F12',
56
+ effectiveShortcut: 'F12',
57
+ isShortcutOverridden: true
58
+ })
59
+
60
+ resetSetupShortcut(entry)
61
+
62
+ expect(entry.installedShortcut).toBe('Ctrl+P')
63
+ expect(entry.effectiveShortcut).toBe('Ctrl+P')
64
+ expect(entry.isShortcutOverridden).toBe(false)
65
+ expect(canResetSetupShortcut(entry)).toBe(false)
66
+ })
67
+
68
+ it('resets pending shortcuts without inventing an installed shortcut', () => {
69
+ const entry = shortcutState({
70
+ defaultShortcut: '',
71
+ installedShortcut: '',
72
+ effectiveShortcut: 'F12'
73
+ })
74
+
75
+ resetSetupShortcut(entry)
76
+
77
+ expect(entry.installedShortcut).toBe('')
78
+ expect(entry.effectiveShortcut).toBe('')
79
+ expect(entry.isShortcutOverridden).toBe(false)
80
+ expect(canResetSetupShortcut(entry)).toBe(false)
81
+ })
82
+
83
+ it('displays custom marker when installed shortcut was changed outside setup', () => {
84
+ const entry = shortcutState({
85
+ installedActionName: 'installed-guid',
86
+ defaultShortcut: '',
87
+ installedShortcut: 'F12',
88
+ effectiveShortcut: 'F12'
89
+ })
90
+
91
+ updateShortcutOverrideState(entry)
92
+
93
+ expect(getDisplayedSetupShortcut(entry)).toBe('F12 [custom]')
94
+ })
95
+ })
@@ -0,0 +1,49 @@
1
+ import { normalizeShortcut } from '@dsf/helpers/shortcut-helper'
2
+
3
+ export const CUSTOM_SHORTCUT_MARKER = '[custom]'
4
+
5
+ export type InstallerShortcutState = {
6
+ installedActionName: string
7
+ installedShortcut: string
8
+ defaultShortcut: string
9
+ effectiveShortcut: string
10
+ isShortcutOverridden: boolean
11
+ }
12
+
13
+ export const updateShortcutOverrideState = (entry: InstallerShortcutState): void => {
14
+ entry.isShortcutOverridden = Boolean(entry.installedActionName) &&
15
+ normalizeShortcut(entry.installedShortcut) !== normalizeShortcut(entry.defaultShortcut)
16
+ }
17
+
18
+ export const getDisplayedSetupShortcut = (entry: InstallerShortcutState): string => {
19
+ if (!entry.isShortcutOverridden) return entry.effectiveShortcut
20
+
21
+ return entry.effectiveShortcut
22
+ ? `${entry.effectiveShortcut} ${CUSTOM_SHORTCUT_MARKER}`
23
+ : CUSTOM_SHORTCUT_MARKER
24
+ }
25
+
26
+ export const canResetSetupShortcut = (entry: InstallerShortcutState): boolean =>
27
+ normalizeShortcut(entry.effectiveShortcut) !== normalizeShortcut(entry.defaultShortcut)
28
+
29
+ export const setSetupShortcut = (entry: InstallerShortcutState, shortcut: string): void => {
30
+ const normalizedShortcut = normalizeShortcut(shortcut)
31
+
32
+ if (entry.installedActionName) {
33
+ entry.installedShortcut = normalizedShortcut
34
+ }
35
+
36
+ entry.effectiveShortcut = normalizedShortcut
37
+ updateShortcutOverrideState(entry)
38
+ }
39
+
40
+ export const resetSetupShortcut = (entry: InstallerShortcutState): void => {
41
+ const defaultShortcut = normalizeShortcut(entry.defaultShortcut)
42
+
43
+ if (entry.installedActionName) {
44
+ entry.installedShortcut = defaultShortcut
45
+ }
46
+
47
+ entry.effectiveShortcut = defaultShortcut
48
+ updateShortcutOverrideState(entry)
49
+ }
@@ -0,0 +1,48 @@
1
+ const shortcutTokenMap: { [key: string]: string } = {
2
+ 'CTRL': 'Ctrl',
3
+ 'CONTROL': 'Ctrl',
4
+ 'SHIFT': 'Shift',
5
+ 'ALT': 'Alt',
6
+ 'OPTION': 'Alt',
7
+ 'WIN': 'Win',
8
+ 'WINDOWS': 'Win',
9
+ 'CMD': 'Win',
10
+ 'COMMAND': 'Win',
11
+ 'SPACE': 'Space',
12
+ 'HOME': 'Home',
13
+ 'END': 'End',
14
+ 'INS': 'Ins',
15
+ 'INSERT': 'Ins',
16
+ 'DEL': 'Del',
17
+ 'DELETE': 'Del',
18
+ 'TAB': 'Tab',
19
+ 'BACKSPACE': 'Backspace',
20
+ 'COMMA': 'Comma',
21
+ 'PERIOD': 'Period',
22
+ 'PLUS': 'Plus',
23
+ 'MINUS': 'Minus',
24
+ 'PGUP': 'PgUp',
25
+ 'PAGEUP': 'PgUp',
26
+ 'PGDOWN': 'PgDn',
27
+ 'PGDN': 'PgDn',
28
+ 'LEFT': 'Left',
29
+ 'RIGHT': 'Right',
30
+ 'UP': 'Up',
31
+ 'DOWN': 'Down',
32
+ }
33
+
34
+ export const normalizeShortcut = (shortcut: string): string => {
35
+ if (!shortcut) return ''
36
+
37
+ return shortcut
38
+ .split('+')
39
+ .map(part => part.trim())
40
+ .filter(Boolean)
41
+ .map(part => {
42
+ if (part.length === 1) return part.toUpperCase()
43
+
44
+ const key = part.toUpperCase()
45
+ return shortcutTokenMap[key] || part
46
+ })
47
+ .join('+')
48
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { clearShortcutConflicts } from './set-keyboard-shortcut-conflicts'
3
+
4
+ describe('clearShortcutConflicts', () => {
5
+ it('clears conflicting actions without clearing the target action', () => {
6
+ const cleared: string[] = []
7
+
8
+ clearShortcutConflicts('target-action', [
9
+ { name: 'target-action', text: 'Target' } as DzAction,
10
+ { name: 'old-owner', text: 'Old Owner' } as DzAction,
11
+ { name: 'other-owner', text: 'Other Owner' } as DzAction
12
+ ], (name) => {
13
+ cleared.push(name)
14
+ })
15
+
16
+ expect(cleared).toEqual(['old-owner', 'other-owner'])
17
+ })
18
+ })
@@ -0,0 +1,14 @@
1
+ export type ShortcutConflict = {
2
+ name: string
3
+ }
4
+
5
+ export const clearShortcutConflicts = (
6
+ actionName: string,
7
+ conflicts: ShortcutConflict[],
8
+ clearShortcut: (name: string) => void
9
+ ): void => {
10
+ conflicts.forEach((action) => {
11
+ if (!action || action.name === actionName) return
12
+ clearShortcut(action.name)
13
+ })
14
+ }
@@ -1,9 +1,10 @@
1
1
  import { BasicDialog } from '@dsf/dialog/basic-dialog';
2
2
  import { debug } from '@dsf/common/log';
3
- import { findAction, findActionsForShortcut, getActionShortcut, normalizeShortcut, setActionShortcut } from '@dsf/helpers/action-helper';
3
+ import { clearActionShortcut, findActionsForShortcut, getActionShortcut, normalizeShortcut, setActionShortcut } from '@dsf/helpers/action-helper';
4
4
  import { contains } from '@dsf/helpers/array-helper';
5
5
  import { confirm } from '@dsf/helpers/message-box-helper';
6
6
  import { Observable } from '@dsf/lib/observable';
7
+ import { clearShortcutConflicts } from './set-keyboard-shortcut-conflicts';
7
8
 
8
9
  class KeyboardShortcutModel {
9
10
  actionLabel: string
@@ -157,6 +158,7 @@ export const promptKeyboardShortcut = (actionLabel: string, actionName: string,
157
158
  const conflictText = conflicts.map(action => action.text || action.name).join(', ')
158
159
  const response = confirm(`"${model.shortcut.value}" is already assigned to: ${conflictText}\n\nReplace it?`)
159
160
  if (!response.ok) return null
161
+ clearShortcutConflicts(actionName, conflicts, clearActionShortcut)
160
162
  }
161
163
 
162
164
  return model.shortcut.value