feat: add workspace header search slot

This commit is contained in:
mirivlad 2026-06-29 04:24:15 +08:00
parent 8f6559cdb6
commit 874b7c7ffb
6 changed files with 168 additions and 12 deletions

View File

@ -144,6 +144,15 @@ export function createPluginAPI(pluginId) {
return { return {
pluginId: pluginId, pluginId: pluginId,
ui: {
openSettings: function(panelId) {
assertActive('ui.openSettings');
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: pluginId, panelId: panelId || '' }
}));
}
},
capabilities: { capabilities: {
has: async function(capId) { has: async function(capId) {
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() { const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {

View File

@ -30,10 +30,10 @@
export let activeSettingsPluginId = ''; export let activeSettingsPluginId = '';
export let activeSettingsPanelId = ''; export let activeSettingsPanelId = '';
$: if (activeSettingsPluginId && activeSettingsPanelId) { $: if (activeSettingsPluginId) {
const key = `${activeSettingsPluginId}:${activeSettingsPanelId}`; const settingsPanelCount = (contributions.settingsPanels || []).length;
if (key !== lastOpenedKey) { const key = `${activeSettingsPluginId}:${activeSettingsPanelId || '*'}`;
lastOpenedKey = key; if (key !== lastOpenedKey || settingsPanelCount === 0) {
openSettingsFromProps(activeSettingsPluginId, activeSettingsPanelId); openSettingsFromProps(activeSettingsPluginId, activeSettingsPanelId);
} }
} }
@ -71,6 +71,7 @@
async function openSettingsFromProps(pluginId, panelId) { async function openSettingsFromProps(pluginId, panelId) {
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId)); const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
if (panel) { if (panel) {
lastOpenedKey = `${pluginId}:${panelId || '*'}`;
settingsPanel = panel; settingsPanel = panel;
settingsPluginId = pluginId; settingsPluginId = pluginId;
settingsError = null; settingsError = null;

View File

@ -1,6 +1,7 @@
<script> <script>
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
let items = []; let items = [];
@ -65,6 +66,10 @@
settingsOpen = false; settingsOpen = false;
} }
function statusItemProps(item) {
return { statusBarItem: item };
}
onMount(() => { onMount(() => {
loadStatusBar(); loadStatusBar();
window.addEventListener('verstak:plugins-changed', loadStatusBar); window.addEventListener('verstak:plugins-changed', loadStatusBar);
@ -92,21 +97,33 @@
</span> </span>
{#each leftItems as item} {#each leftItems as item}
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}> <span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
{#if item.handler}
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
{:else}
{item.label || item.id} {item.label || item.id}
{/if}
</span> </span>
{/each} {/each}
</div> </div>
<div class="status-bar-group status-center"> <div class="status-bar-group status-center">
{#each centerItems as item} {#each centerItems as item}
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}> <span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
{#if item.handler}
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
{:else}
{item.label || item.id} {item.label || item.id}
{/if}
</span> </span>
{/each} {/each}
</div> </div>
<div class="status-bar-group status-right"> <div class="status-bar-group status-right">
{#each rightItems as item} {#each rightItems as item}
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}> <span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
{#if item.handler}
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
{:else}
{item.label || item.id} {item.label || item.id}
{/if}
</span> </span>
{/each} {/each}
<div class="settings-menu-wrap"> <div class="settings-menu-wrap">

View File

@ -10,6 +10,15 @@
let workspaceTools = []; let workspaceTools = [];
let activeToolKey = ''; let activeToolKey = '';
const toolOrder = new Map([
['notes', 10],
['files', 20],
['activity', 40],
['browser', 50],
['inbox', 50],
['search', 90],
]);
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null; $: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
@ -24,6 +33,22 @@
return `${tool?.pluginId || ''}:${tool?.id || ''}`; return `${tool?.pluginId || ''}:${tool?.id || ''}`;
} }
function toolRank(tool) {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
for (const [needle, rank] of toolOrder.entries()) {
if (text.includes(needle)) return rank;
}
return 1000;
}
function sortWorkspaceTools(tools) {
return [...tools].sort((a, b) => {
const rankDiff = toolRank(a) - toolRank(b);
if (rankDiff !== 0) return rankDiff;
return String(a.title || a.id).localeCompare(String(b.title || b.id));
});
}
async function loadTools() { async function loadTools() {
try { try {
const [c, p] = await Promise.all([ const [c, p] = await Promise.all([
@ -37,7 +62,7 @@
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id) plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
); );
workspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId)); workspaceTools = sortWorkspaceTools((contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId)));
} catch (e) { } catch (e) {
console.error('[WorkspaceHost] loadTools error:', e); console.error('[WorkspaceHost] loadTools error:', e);
} }
@ -47,9 +72,14 @@
<div class="workspace-host"> <div class="workspace-host">
{#if selectedWorkspace} {#if selectedWorkspace}
<div class="workspace-header"> <div class="workspace-header">
<div class="workspace-title-group">
<span class="workspace-title">{workspaceTitle}</span> <span class="workspace-title">{workspaceTitle}</span>
<span class="workspace-type">{workspaceType}</span> <span class="workspace-type">{workspaceType}</span>
</div> </div>
<div class="workspace-search" data-workspace-search>
<input type="search" placeholder="Search workspace" aria-label="Search workspace" />
</div>
</div>
{#if workspaceTools.length > 0} {#if workspaceTools.length > 0}
<div class="workspace-tabs" role="tablist" aria-label="Workspace tools"> <div class="workspace-tabs" role="tablist" aria-label="Workspace tools">
@ -101,12 +131,20 @@
.workspace-header { .workspace-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e; border-bottom: 1px solid #16213e;
flex-shrink: 0; flex-shrink: 0;
} }
.workspace-title-group {
min-width: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.workspace-title { .workspace-title {
color: #e0e0f0; color: #e0e0f0;
font-size: 0.95rem; font-size: 0.95rem;
@ -121,6 +159,40 @@
background: #1a2a3a; background: #1a2a3a;
} }
.workspace-search {
flex: 0 1 22rem;
min-width: 12rem;
}
.workspace-search input {
width: 100%;
height: 2rem;
padding: 0.25rem 0.55rem;
border: 1px solid #283653;
border-radius: 4px;
background: #101626;
color: #e0e0f0;
font: inherit;
font-size: 0.82rem;
outline: none;
}
.workspace-search input:focus {
border-color: #4ecca3;
}
@media (max-width: 720px) {
.workspace-header {
align-items: stretch;
flex-direction: column;
}
.workspace-search {
flex-basis: auto;
min-width: 0;
}
}
.workspace-tabs { .workspace-tabs {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -152,7 +152,7 @@
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
settingsPanels: [{ id: 'verstak.sync.settings', title: 'Sync', component: 'SyncSettings' }], settingsPanels: [{ id: 'verstak.sync.settings', title: 'Sync', component: 'SyncSettings' }],
statusBarItems: [{ id: 'verstak.sync.status', label: 'Sync', position: 'right' }] statusBarItems: [{ id: 'verstak.sync.status', label: 'Sync', position: 'right', handler: 'SyncStatusBar' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/sync', rootPath: '/tmp/verstak-test/plugins/sync',
@ -1703,7 +1703,7 @@
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
settingsPanels: [{ id: 'verstak.sync.settings', title: 'Sync', component: 'SyncSettings' }], settingsPanels: [{ id: 'verstak.sync.settings', title: 'Sync', component: 'SyncSettings' }],
statusBarItems: [{ id: 'verstak.sync.status', label: 'Sync', position: 'right' }] statusBarItems: [{ id: 'verstak.sync.status', label: 'Sync', position: 'right', handler: 'SyncStatusBar' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/sync', rootPath: '/tmp/verstak-test/plugins/sync',

View File

@ -0,0 +1,57 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve('.');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
function assertIncludes(source, needle, message) {
if (!source.includes(needle)) {
throw new Error(message);
}
}
const workspaceHost = read('frontend/src/lib/shell/WorkspaceHost.svelte');
const statusBar = read('frontend/src/lib/shell/StatusBar.svelte');
const pluginManager = read('frontend/src/lib/plugin-manager/PluginManager.svelte');
const syncManifest = JSON.parse(read('../verstak-official-plugins/plugins/sync/plugin.json'));
assertIncludes(
workspaceHost,
'data-workspace-search',
'WorkspaceHost should expose a stable workspace header search slot'
);
assertIncludes(
workspaceHost,
'toolOrder',
'WorkspaceHost should define usage-based workspace tool ordering'
);
assertIncludes(
workspaceHost,
'sortWorkspaceTools',
'WorkspaceHost should sort workspace tools by expected usage'
);
assertIncludes(
statusBar,
"import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';",
'StatusBar should mount plugin-provided status bar components'
);
assertIncludes(
statusBar,
'componentId={item.handler}',
'StatusBar should use statusBarItems.handler as the component id'
);
const syncStatus = syncManifest.contributes.statusBarItems.find((item) => item.id === 'verstak.sync.status');
if (!syncStatus || syncStatus.handler !== 'SyncStatusBar') {
throw new Error('Sync statusBarItem should declare handler "SyncStatusBar"');
}
if (/lastOpenedKey\s*=\s*key;\s*openSettingsFromProps\(activeSettingsPluginId,\s*activeSettingsPanelId\)/s.test(pluginManager)) {
throw new Error('PluginManager should not mark settings panel as opened before resolving contributions');
}
console.log('shell source contract smoke passed');