fix(app): use the project menu pattern for editor picker (#30)
Replace the input-like native select with the same compact button and floating menu vocabulary used by existing source filters.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Settings' });
|
||||
|
||||
@@ -8,15 +8,30 @@ const dbPath = ref('');
|
||||
const recapPath = ref('');
|
||||
const autoRefresh = ref(true);
|
||||
const editorScheme = ref('vscode');
|
||||
const editorSchemes = ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'zed'];
|
||||
const editorSchemes = [
|
||||
{ id: 'vscode', label: 'VS Code' },
|
||||
{ id: 'vscode-insiders', label: 'VS Code Insiders' },
|
||||
{ id: 'cursor', label: 'Cursor' },
|
||||
{ id: 'windsurf', label: 'Windsurf' },
|
||||
{ id: 'zed', label: 'Zed' },
|
||||
];
|
||||
const editorPicker = ref(null);
|
||||
const editorMenuOpen = ref(false);
|
||||
const memoryCount = ref(0);
|
||||
const rebuilding = ref(false);
|
||||
const version = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('pointerdown', closeEditorMenuOutside);
|
||||
document.addEventListener('keydown', closeEditorMenuOnEscape);
|
||||
await loadSettings();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', closeEditorMenuOutside);
|
||||
document.removeEventListener('keydown', closeEditorMenuOnEscape);
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
if (!window.obelisk?.getSettings) return;
|
||||
const s = await window.obelisk.getSettings();
|
||||
@@ -34,6 +49,23 @@ async function saveEditorScheme(value) {
|
||||
await saveSetting('editorScheme', value);
|
||||
}
|
||||
|
||||
function editorLabel(value = editorScheme.value) {
|
||||
return editorSchemes.find(editor => editor.id === value)?.label || 'VS Code';
|
||||
}
|
||||
|
||||
function closeEditorMenuOutside(event) {
|
||||
if (!editorPicker.value?.contains(event.target)) editorMenuOpen.value = false;
|
||||
}
|
||||
|
||||
function closeEditorMenuOnEscape(event) {
|
||||
if (event.key === 'Escape') editorMenuOpen.value = false;
|
||||
}
|
||||
|
||||
async function chooseEditor(value) {
|
||||
editorMenuOpen.value = false;
|
||||
await saveEditorScheme(value);
|
||||
}
|
||||
|
||||
async function browseSourcePath(source) {
|
||||
if (!window.obelisk?.browseFolder) return;
|
||||
const result = await window.obelisk.browseFolder();
|
||||
@@ -188,10 +220,46 @@ function fmtRelative(iso) {
|
||||
<div class="form-label">Editor URL scheme</div>
|
||||
<div class="form-label-hint">Clicking <code>src/app.ts:42</code> jumps to that line.</div>
|
||||
</div>
|
||||
<div class="form-control select-control">
|
||||
<select class="path-field select-field" :value="editorScheme" @change="saveEditorScheme($event.target.value)">
|
||||
<option v-for="opt in editorSchemes" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<div class="form-control">
|
||||
<div ref="editorPicker" class="editor-picker">
|
||||
<button
|
||||
type="button"
|
||||
class="editor-picker-trigger"
|
||||
:class="{ open: editorMenuOpen }"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="String(editorMenuOpen)"
|
||||
aria-controls="editor-picker-menu"
|
||||
@click="editorMenuOpen = !editorMenuOpen"
|
||||
>
|
||||
<span>{{ editorLabel() }}</span>
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 4.5 6 7.5 9 4.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
id="editor-picker-menu"
|
||||
class="editor-picker-menu"
|
||||
:class="{ show: editorMenuOpen }"
|
||||
role="listbox"
|
||||
aria-label="Editor URL scheme"
|
||||
>
|
||||
<button
|
||||
v-for="editor in editorSchemes"
|
||||
:key="editor.id"
|
||||
type="button"
|
||||
class="editor-picker-option"
|
||||
:class="{ selected: editor.id === editorScheme }"
|
||||
role="option"
|
||||
:aria-selected="String(editor.id === editorScheme)"
|
||||
@click="chooseEditor(editor.id)"
|
||||
>
|
||||
<span>{{ editor.label }}</span>
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M2.5 6 5 8.5 9.5 3.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -340,19 +408,42 @@ function fmtRelative(iso) {
|
||||
.path-field:focus { outline: 0; border-color: var(--accent); background: rgba(0,0,0,0.4); box-shadow: 0 0 0 2px rgba(167,139,250,0.12); }
|
||||
.path-field.error { border-color: rgba(248,113,113,0.4); }
|
||||
.path-field.error:focus { border-color: #f87171; box-shadow: 0 0 0 2px rgba(248,113,113,0.12); }
|
||||
.select-control { position: relative; }
|
||||
.select-control::after {
|
||||
content: ''; position: absolute; right: 11px; top: 9px;
|
||||
width: 6px; height: 6px; pointer-events: none;
|
||||
border-right: 1px solid var(--muted); border-bottom: 1px solid var(--muted);
|
||||
transform: rotate(45deg);
|
||||
.editor-picker { position: relative; width: 180px; }
|
||||
.editor-picker-trigger {
|
||||
width: 100%; height: 28px; padding: 0 9px 0 10px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
border: 1px solid var(--hairline-strong); border-radius: 5px;
|
||||
background: var(--surface); color: var(--fg-2);
|
||||
font-size: 12px; font-weight: 500; text-align: left;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.select-field {
|
||||
width: 100%; padding-right: 30px; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
.editor-picker-trigger:hover,
|
||||
.editor-picker-trigger.open { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
|
||||
.editor-picker-trigger:focus-visible {
|
||||
outline: 0; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(167,139,250,0.12);
|
||||
}
|
||||
.select-field:hover { border-color: var(--hairline-vivid); }
|
||||
.select-field option { background: var(--surface-strong); color: var(--fg); }
|
||||
.editor-picker-trigger svg { width: 12px; height: 12px; color: var(--muted); flex-shrink: 0; transition: transform 0.15s; }
|
||||
.editor-picker-trigger.open svg { transform: rotate(180deg); color: var(--fg-2); }
|
||||
.editor-picker-menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; z-index: 100;
|
||||
width: 100%; padding: 4px;
|
||||
background: rgba(20,22,38,0.98); border: 1px solid var(--hairline-strong); border-radius: 7px;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
opacity: 0; transform: translateY(-4px); pointer-events: none;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.editor-picker-menu.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||
.editor-picker-option {
|
||||
width: 100%; height: 29px; padding: 0 8px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
border-radius: 4px; color: var(--muted); font-size: 12px; text-align: left;
|
||||
transition: background 0.08s, color 0.08s;
|
||||
}
|
||||
.editor-picker-option:hover,
|
||||
.editor-picker-option:focus-visible { outline: 0; background: rgba(255,255,255,0.04); color: var(--fg); }
|
||||
.editor-picker-option.selected { color: var(--fg-2); }
|
||||
.editor-picker-option svg { width: 12px; height: 12px; color: var(--accent-2); opacity: 0; }
|
||||
.editor-picker-option.selected svg { opacity: 1; }
|
||||
.tz-field { max-width: 240px; }
|
||||
|
||||
.btn {
|
||||
|
||||
@@ -25,11 +25,13 @@ const channels = [
|
||||
'db:getProjects',
|
||||
'db:getStats',
|
||||
'settings:get',
|
||||
'settings:set',
|
||||
'file-ref:open',
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
const openCalls = [];
|
||||
const settingCalls = [];
|
||||
|
||||
const messageText = [
|
||||
'Absolute link: [roadmap.md](/tmp/obelisk-file-ref-fixture/docs/roadmap.md:162)',
|
||||
@@ -113,6 +115,10 @@ function registerHandlers() {
|
||||
editorScheme: 'vscode',
|
||||
version: '9.8.7-test',
|
||||
}));
|
||||
ipcMain.handle('settings:set', (_event, key, value) => {
|
||||
settingCalls.push({ key, value });
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle('file-ref:open', (_event, ref) => {
|
||||
openCalls.push(ref);
|
||||
return { opened: false };
|
||||
@@ -199,26 +205,62 @@ async function run() {
|
||||
);
|
||||
|
||||
const settingsState = await win.webContents.executeJavaScript(`(() => {
|
||||
const select = document.querySelector('select.select-field');
|
||||
const style = select ? getComputedStyle(select) : null;
|
||||
const trigger = document.querySelector('.editor-picker-trigger');
|
||||
const style = trigger ? getComputedStyle(trigger) : null;
|
||||
return {
|
||||
exists: Boolean(select),
|
||||
appearance: style?.appearance || style?.webkitAppearance || null,
|
||||
exists: Boolean(trigger),
|
||||
width: trigger?.getBoundingClientRect().width || null,
|
||||
backgroundColor: style?.backgroundColor || null,
|
||||
color: style?.color || null,
|
||||
expanded: trigger?.getAttribute('aria-expanded') || null,
|
||||
label: trigger?.textContent?.trim() || null,
|
||||
nativeSelects: document.querySelectorAll('select').length,
|
||||
version: document.querySelector('.version-text')?.textContent?.trim() || null,
|
||||
};
|
||||
})()`, true);
|
||||
|
||||
assert(settingsState.exists, 'Settings renders the themed editor selector');
|
||||
assert(settingsState.appearance === 'none', `editor selector disables native appearance (${settingsState.appearance})`);
|
||||
assert(settingsState.width === 180, `editor picker stays compact (${settingsState.width}px)`);
|
||||
assert(
|
||||
settingsState.backgroundColor !== 'rgb(255, 255, 255)',
|
||||
`editor selector keeps the dark Settings surface (${settingsState.backgroundColor})`,
|
||||
`editor picker keeps the dark Settings surface (${settingsState.backgroundColor})`,
|
||||
);
|
||||
assert(settingsState.color !== 'rgb(0, 0, 0)', `editor selector keeps themed text (${settingsState.color})`);
|
||||
assert(settingsState.color !== 'rgb(0, 0, 0)', `editor picker keeps themed text (${settingsState.color})`);
|
||||
assert(settingsState.expanded === 'false', 'editor picker starts collapsed');
|
||||
assert(settingsState.label.includes('VS Code'), `editor picker uses a readable label (${settingsState.label})`);
|
||||
assert(settingsState.nativeSelects === 0, 'Settings does not fall back to a native select');
|
||||
assert(settingsState.version === 'Obelisk 9.8.7-test', `Settings renders the IPC app version (${settingsState.version})`);
|
||||
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('.editor-picker-trigger').click()`, true,
|
||||
);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.editor-picker-menu.show [role="option"]')`,
|
||||
'editor picker menu',
|
||||
);
|
||||
const openPicker = await win.webContents.executeJavaScript(`(() => ({
|
||||
expanded: document.querySelector('.editor-picker-trigger')?.getAttribute('aria-expanded'),
|
||||
menuWidth: document.querySelector('.editor-picker-menu')?.getBoundingClientRect().width,
|
||||
options: [...document.querySelectorAll('.editor-picker-option')].map(option => ({
|
||||
label: option.textContent.trim(),
|
||||
selected: option.getAttribute('aria-selected'),
|
||||
})),
|
||||
}))()`, true);
|
||||
assert(openPicker.expanded === 'true', 'editor picker exposes its expanded state');
|
||||
assert(openPicker.menuWidth === 180, `editor picker menu matches trigger width (${openPicker.menuWidth}px)`);
|
||||
assert(openPicker.options.length === 5, `editor picker exposes five editors (${openPicker.options.length})`);
|
||||
assert(openPicker.options.some(option => option.label.includes('VS Code') && option.selected === 'true'), 'current editor is marked selected');
|
||||
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('.editor-picker-option')].find(option => option.textContent.includes('Cursor')).click()`, true,
|
||||
);
|
||||
await delay(100);
|
||||
assert(
|
||||
settingCalls.some(call => call.key === 'editorScheme' && call.value === 'cursor'),
|
||||
`editor picker persists the selected scheme (${JSON.stringify(settingCalls)})`,
|
||||
);
|
||||
|
||||
win.destroy();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user