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:
tommy0103
2026-08-04 04:57:01 +08:00
committed by GitHub
parent 23adfa3847
commit 47df0fe76a
3 changed files with 163 additions and 27 deletions
+108 -17
View File
@@ -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 {
+49 -7
View File
@@ -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();
}