mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(tiles): the per-device Tiles setting, and Ctrl+Shift+G follows it
showTileGridButton gets the full per-device treatment Split has: a header chip in App Settings beside Split, its load and save lines, OFF by default (and in the handheld defaults), a member of the displayKeys merge policy, stripped from the settings PUT and never declared in the .strict() SettingsUpdateSchema (sending it would 400 the whole save). The setting also gates the Ctrl+Shift+G toggle (the applied default while the owner's answer is pending; one line in tileShortcutFor to change): OFF, the chord is inert and reaches the terminal like any unbound key; ON, it opens and closes the grid where one can open. A grid that is open however it was opened (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords, the toggle that closes it included. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1921,6 +1921,7 @@
|
|||||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||||
|
<label class="set-chip" data-preview="header" data-preview-order="11.6"><input type="checkbox" id="appSettingsShowTileGridButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg><span>Tiles</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||||
|
document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
|
||||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||||
@@ -2492,6 +2493,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||||
|
showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,
|
||||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||||
@@ -2730,6 +2732,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||||
// (moving it into displayKeys alone is not the strip; this is).
|
// (moving it into displayKeys alone is not the strip; this is).
|
||||||
showSplitButton: _ssp,
|
showSplitButton: _ssp,
|
||||||
|
// Same as Split: a per-device header button (and the Tiles chord), absent
|
||||||
|
// from SettingsUpdateSchema (.strict()), so sending it 400s the whole PUT.
|
||||||
|
showTileGridButton: _stg,
|
||||||
webglRendererEnabled: _wgl,
|
webglRendererEnabled: _wgl,
|
||||||
terminalWheelLocalScrollback: _twls,
|
terminalWheelLocalScrollback: _twls,
|
||||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||||
@@ -3420,6 +3425,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ultracodeFloatingWindows: false,
|
ultracodeFloatingWindows: false,
|
||||||
showMultiMonitorButton: false,
|
showMultiMonitorButton: false,
|
||||||
showSplitButton: false,
|
showSplitButton: false,
|
||||||
|
showTileGridButton: false,
|
||||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||||
// policy guard keeps passing.
|
// policy guard keeps passing.
|
||||||
@@ -4057,6 +4063,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
'sessionLineageLines',
|
'sessionLineageLines',
|
||||||
'showSplitButton',
|
'showSplitButton',
|
||||||
|
'showTileGridButton',
|
||||||
]);
|
]);
|
||||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||||
|
|||||||
@@ -552,11 +552,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tile chord `e` asks for, if it applies right now, else null: the toggle
|
* The tile chord `e` asks for, if it applies right now, else null. The
|
||||||
* wherever a grid could open (or is open), the focus and remove chords only
|
* toggle applies while the grid is open, or where one could open AND the
|
||||||
* while it is open, so outside the grid they reach the terminal untouched.
|
* per-device `showTileGridButton` setting is on: with it off (the default)
|
||||||
* Registry-aware (rebinds and disables in App Settings, Shortcuts). The
|
* the chord is inert and reaches the terminal like any unbound key. (The
|
||||||
* capture handler (app.js) dispatches it; every xterm key handler returns
|
* applied default pending the owner's answer; one line to change.) The focus,
|
||||||
|
* zoom and remove chords apply only while the grid is open, however it was
|
||||||
|
* opened. Registry-aware (rebinds and disables in App Settings, Shortcuts).
|
||||||
|
* The capture handler (app.js) dispatches it; every xterm key handler returns
|
||||||
* false for it, so a chord that applies never reaches a PTY.
|
* false for it, so a chord that applies never reaches a PTY.
|
||||||
*
|
*
|
||||||
* @returns {string|null} the registry id
|
* @returns {string|null} the registry id
|
||||||
@@ -568,8 +571,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
for (const shortcut of this.getShortcutRegistry()) {
|
for (const shortcut of this.getShortcutRegistry()) {
|
||||||
const spec = TILE_SHORTCUTS[shortcut.id];
|
const spec = TILE_SHORTCUTS[shortcut.id];
|
||||||
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
|
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
|
||||||
if (spec.needsOpen ? open : open || this.canOpenTileGrid()) return shortcut.id;
|
if (spec.needsOpen) return open ? shortcut.id : null;
|
||||||
return null;
|
const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true;
|
||||||
|
return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// test/tile-grid-per-device-setting.test.ts
|
||||||
|
// Port: none (pure static analysis, runs in CI, no browser/server).
|
||||||
|
//
|
||||||
|
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
|
||||||
|
// a PER-DEVICE setting, default OFF, with the same three-way rule as
|
||||||
|
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
|
||||||
|
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
|
||||||
|
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
|
||||||
|
// it would 400 the whole save), and never declared in the schema. Plus the
|
||||||
|
// parts that make the setting reachable: the App Settings chip, its load and
|
||||||
|
// save lines, the mobile default, and the `--hidden` marker's display:none
|
||||||
|
// rule (also covered generically by split-pane-hidden-button-css.test.ts).
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const PUBLIC = join(HERE, '../src/web/public');
|
||||||
|
const read = (file: string) => readFileSync(join(PUBLIC, file), 'utf8');
|
||||||
|
|
||||||
|
describe('showTileGridButton stays per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||||
|
const settingsUi = read('settings-ui.js');
|
||||||
|
const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8');
|
||||||
|
|
||||||
|
it('is in the client-side displayKeys merge policy', () => {
|
||||||
|
const start = settingsUi.indexOf('const displayKeys = new Set([');
|
||||||
|
const displayKeys = settingsUi.slice(start, settingsUi.indexOf('])', start));
|
||||||
|
expect(displayKeys).toContain("'showTileGridButton'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stripped out of the object saveAppSettings() PUTs to the server', () => {
|
||||||
|
expect(settingsUi).toContain('showTileGridButton: _stg,');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is never declared in the .strict() SettingsUpdateSchema', () => {
|
||||||
|
expect(schemas).not.toContain('showTileGridButton');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults OFF on handhelds (and reads as OFF when absent everywhere)', () => {
|
||||||
|
expect(settingsUi).toMatch(/showSplitButton: false,\s*showTileGridButton: false,/);
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
"document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is saved from its App Settings chip and applied to the header', () => {
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
"showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,"
|
||||||
|
);
|
||||||
|
expect(settingsUi).toContain('this._applyTileGridButtonVisibility?.(showTileGridButton);');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markup and styles', () => {
|
||||||
|
const html = read('index.html');
|
||||||
|
const css = read('styles.css');
|
||||||
|
|
||||||
|
it('has a header chip in App Settings, beside Split', () => {
|
||||||
|
expect(html).toMatch(
|
||||||
|
/data-preview-order="11\.6"><input type="checkbox" id="appSettingsShowTileGridButton">[\s\S]*?<span>Tiles<\/span>/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ships the header button hidden by its marker class, which has a display:none rule', () => {
|
||||||
|
expect(html).toContain('class="btn-icon-header btn-tile-grid btn-tile-grid--hidden"');
|
||||||
|
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,11 @@ const chord = (overrides: Record<string, unknown>) => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true };
|
const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true };
|
||||||
|
/** The per-device Tiles setting on (it enables the Tiles button and the toggle chord). */
|
||||||
|
const withTilesSetting = (app: GridApp) => {
|
||||||
|
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true });
|
||||||
|
return app;
|
||||||
|
};
|
||||||
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
|
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -71,15 +76,27 @@ describe('registry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when a chord applies', () => {
|
describe('when a chord applies', () => {
|
||||||
it('the toggle applies wherever a grid could open, and while one is open', () => {
|
it('with the Tiles setting on, the toggle applies wherever a grid could open, and while one is open', () => {
|
||||||
const app = makeGridApp(IDS);
|
const app = withTilesSetting(makeGridApp(IDS));
|
||||||
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
|
||||||
app.openTileGrid(IDS);
|
app.openTileGrid(IDS);
|
||||||
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the toggle does not apply in a narrow window or a solo window', () => {
|
it('with the Tiles setting off (the default) the toggle is inert, like an unbound key', () => {
|
||||||
const app = makeGridApp(IDS);
|
const app = makeGridApp(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the setting off, a grid opened another way (Ctrl+click, a drop) still has its chords, toggle included', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(IDS);
|
||||||
|
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
|
||||||
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the toggle does not apply in a narrow window or a solo window', () => {
|
||||||
|
const app = withTilesSetting(makeGridApp(IDS));
|
||||||
gridWindow.innerWidth = 1000;
|
gridWindow.innerWidth = 1000;
|
||||||
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||||
gridWindow.innerWidth = 2400;
|
gridWindow.innerWidth = 2400;
|
||||||
@@ -131,8 +148,17 @@ describe('the capture-phase handler', () => {
|
|||||||
return keydown[1];
|
return keydown[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => {
|
it('setting off: Ctrl+Shift+G is left alone (no preventDefault), the grid stays closed', () => {
|
||||||
const app = makeGridApp(IDS);
|
const app = makeGridApp(IDS);
|
||||||
|
const onKeydown = handlerFor(app);
|
||||||
|
const e = chord(TOGGLE);
|
||||||
|
onKeydown(e);
|
||||||
|
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => {
|
||||||
|
const app = withTilesSetting(makeGridApp(IDS));
|
||||||
app.selectSession = vi.fn();
|
app.selectSession = vi.fn();
|
||||||
const onKeydown = handlerFor(app);
|
const onKeydown = handlerFor(app);
|
||||||
const open = chord(TOGGLE);
|
const open = chord(TOGGLE);
|
||||||
|
|||||||
Reference in New Issue
Block a user