verstak-desktop/frontend/src/App.svelte

863 lines
24 KiB
Svelte

<script>
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import CommandPalette from './lib/shell/CommandPalette.svelte';
import StatusBar from './lib/shell/StatusBar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onDestroy, onMount, tick } from 'svelte';
import { i18n } from './lib/i18n/index.js';
let currentView = 'workspace';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false;
let loading = true;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
let activeView = null;
let activeViewPluginId = '';
let activeSettingsPluginId = '';
let activeSettingsPanelId = '';
let openedResource = null;
let workspaceNodes = [];
let selectedWorkspaceName = '';
let activeWorkspaceToolKey = '';
let navigationStack = [];
let navigationIndex = -1;
let applyingNavigation = false;
let lastMouseHistoryDirection = '';
let lastMouseHistoryAt = 0;
function flog(msg) {
App.WriteFrontendLog('App', msg);
}
function resultOrError(response, fallbackValue) {
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
function workspaceName(workspace) {
return String(workspace?.name || workspace?.rootPath || workspace?.id || '');
}
function workspaceAsNode(workspace, order) {
const name = workspaceName(workspace);
return {
id: name,
type: workspace?.type || 'space',
title: workspace?.title || name,
name,
rootPath: workspace?.rootPath || name,
status: workspace?.status || 'active',
order,
};
}
function emitWorkspaceActive(name) {
window.dispatchEvent(new CustomEvent('verstak:workspace-active-changed', {
detail: { workspaceName: name || '' }
}));
}
function clearWorkspaceSelection() {
selectedWorkspaceName = '';
emitWorkspaceActive('');
}
async function openDefaultWorkspaceRoute() {
try {
const [workspaces, err] = resultOrError(await App.ListWorkspaces(), []);
if (err || !workspaces || workspaces.length === 0) {
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
return;
}
workspaceNodes = workspaces.map(workspaceAsNode);
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = workspaceName(currentWorkspace);
const selected = workspaces.find((workspace) => workspaceName(workspace) === currentName) || workspaces[0];
selectedWorkspaceName = workspaceName(selected);
if (selectedWorkspaceName) {
try { await App.SetCurrentWorkspace(selectedWorkspaceName); } catch {}
currentView = 'workspace';
} else {
currentView = 'workspace-empty';
}
emitWorkspaceActive(selectedWorkspaceName);
} catch (e) {
debug.log('[App] openDefaultWorkspaceRoute ERROR', String(e));
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
}
}
function currentSnapshot() {
return {
currentView,
activeView,
activeViewPluginId,
activeSettingsPluginId,
activeSettingsPanelId,
openedResource,
selectedWorkspaceName,
activeWorkspaceToolKey,
};
}
function sameSnapshot(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
function pushNavigation(snapshot = currentSnapshot()) {
if (applyingNavigation) return;
if (navigationIndex >= 0 && sameSnapshot(navigationStack[navigationIndex], snapshot)) return;
if (navigationIndex < navigationStack.length - 1) {
navigationStack = navigationStack.slice(0, navigationIndex + 1);
}
navigationStack = [...navigationStack, snapshot];
navigationIndex = navigationStack.length - 1;
}
function applySnapshot(snapshot) {
applyingNavigation = true;
currentView = snapshot.currentView;
activeView = snapshot.activeView;
activeViewPluginId = snapshot.activeViewPluginId;
activeSettingsPluginId = snapshot.activeSettingsPluginId;
activeSettingsPanelId = snapshot.activeSettingsPanelId;
openedResource = snapshot.openedResource;
selectedWorkspaceName = snapshot.selectedWorkspaceName;
activeWorkspaceToolKey = snapshot.activeWorkspaceToolKey || '';
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
applyingNavigation = false;
}
function navigateBack() {
if (navigationIndex <= 0) return false;
navigationIndex -= 1;
applySnapshot(navigationStack[navigationIndex]);
return true;
}
function navigateForward() {
if (navigationIndex >= navigationStack.length - 1) return false;
navigationIndex += 1;
applySnapshot(navigationStack[navigationIndex]);
return true;
}
function mouseHistoryDirection(event) {
if (currentView === 'workspace') return '';
if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back';
if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward';
return '';
}
function keyHistoryDirection(event) {
if (currentView === 'workspace' || currentView === 'workbench') return '';
const key = event.key || '';
if (event.altKey && key === 'ArrowLeft') return 'back';
if (event.altKey && key === 'ArrowRight') return 'forward';
if (key === 'BrowserBack' || key === 'XF86Back') return 'back';
if (key === 'BrowserForward' || key === 'XF86Forward') return 'forward';
if (event.keyCode === 166) return 'back';
if (event.keyCode === 167) return 'forward';
return '';
}
function handleHistoryRequest(direction, event) {
if (!direction || event?.defaultPrevented) return;
if (event?.type === 'mousedown' || event?.type === 'mouseup' || event?.type === 'auxclick' || event?.type === 'pointerdown') {
const now = Date.now();
if (direction === lastMouseHistoryDirection && now - lastMouseHistoryAt < 120) return;
lastMouseHistoryDirection = direction;
lastMouseHistoryAt = now;
debug.log('[App] mouse history event', {
type: event.type,
direction,
button: event.button,
buttons: event.buttons,
which: event.which,
pointerType: event.pointerType || '',
currentView,
});
}
const moved = direction === 'back' ? navigateBack() : navigateForward();
if (moved && event) {
event.preventDefault();
event.stopPropagation();
}
}
async function checkVault() {
debug.log('[App] checkVault: START');
flog('checkVault: START');
loading = true;
try {
debug.log('[App] checkVault: calling GetAppSettings...');
const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned');
debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
debug.log('[App] checkVault: GetVaultStatus returned', vaultStatus);
flog('checkVault: vaultStatus=' + vaultStatus.status);
if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
debug.log('[App] checkVault: vault not open, needsVaultSelection=true');
flog('checkVault: needsVaultSelection=true');
needsVaultSelection = true;
} else {
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
flog('checkVault: needsVaultSelection=false');
needsVaultSelection = false;
await openDefaultWorkspaceRoute();
}
} catch (e) {
debug.log('[App] checkVault: ERROR', String(e));
flog('checkVault: ERROR: ' + String(e));
console.error('[App] startup check failed:', e);
needsVaultSelection = true;
}
loading = false;
await tick();
debug.log('[App] checkVault: END, loading=false');
flog('checkVault: END, loading=false');
}
async function onVaultOpened() {
debug.log('[App] onVaultOpened');
needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' };
await openDefaultWorkspaceRoute();
pushNavigation();
}
function onNav(e) {
debug.log('[App] onNav:', e.detail.viewId);
currentView = e.detail.viewId;
if (currentView !== 'workspace') clearWorkspaceSelection();
pushNavigation();
}
function onOpenView(e) {
debug.log('[App] onOpenView:', e.detail.viewId, 'plugin:', e.detail.pluginId);
activeView = e.detail.viewId;
activeViewPluginId = e.detail.pluginId || '';
currentView = 'plugin-view';
clearWorkspaceSelection();
pushNavigation();
}
function onOpenSettings(e) {
debug.log('[App] onOpenSettings:', e.detail.pluginId, e.detail.panelId);
activeSettingsPluginId = e.detail.pluginId;
activeSettingsPanelId = e.detail.panelId || '';
currentView = 'plugin-manager';
clearWorkspaceSelection();
pushNavigation();
}
function onWorkbenchOpened(e) {
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
if (currentView === 'workspace') pushNavigation();
openedResource = e.detail;
currentView = 'workbench';
pushNavigation();
}
function onWorkspaceToolSelected(e) {
activeWorkspaceToolKey = e.detail?.toolKey || '';
if (currentView === 'workspace') pushNavigation();
}
function onWorkspaceSelected(e) {
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
selectedWorkspaceName = e.detail?.workspaceName || '';
workspaceNodes = e.detail?.nodes || workspaceNodes;
if (selectedWorkspaceName) {
activeView = null;
activeViewPluginId = '';
activeSettingsPluginId = '';
activeSettingsPanelId = '';
openedResource = null;
currentView = 'workspace';
emitWorkspaceActive(selectedWorkspaceName);
pushNavigation();
}
}
function onCloseSettings() {
debug.log('[App] onCloseSettings');
activeSettingsPluginId = '';
activeSettingsPanelId = '';
}
function onNavigateBack(e) {
if (currentView === 'workbench') return;
if (currentView === 'workspace') {
const upBtn = document.querySelector('[data-files-action="up"]');
if (upBtn && !upBtn.disabled) {
upBtn.click();
e?.preventDefault?.();
return;
}
}
if (navigateBack()) e?.preventDefault?.();
}
function onNavigateForward(e) {
if (currentView === 'workbench') return;
if (currentView === 'workspace') {
const fwdBtn = document.querySelector('[data-files-action="forward"]');
if (fwdBtn && !fwdBtn.disabled) {
fwdBtn.click();
e?.preventDefault?.();
return;
}
}
if (navigateForward()) e?.preventDefault?.();
}
function onCloseWorkbench(e) {
if (currentView !== 'workbench') return;
if (!navigateBack() && selectedWorkspaceName) {
currentView = 'workspace';
pushNavigation();
}
e?.preventDefault?.();
}
function onGlobalKeydown(e) {
handleHistoryRequest(keyHistoryDirection(e), e);
}
function onGlobalMouse(e) {
handleHistoryRequest(mouseHistoryDirection(e), e);
}
// Listen for events
if (typeof window !== 'undefined') {
window.addEventListener('verstak:vault-opened', onVaultOpened);
window.addEventListener('verstak:nav', onNav);
window.addEventListener('verstak:open-view', onOpenView);
window.addEventListener('verstak:open-settings', onOpenSettings);
window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
window.addEventListener('verstak:workspace-tool-selected', onWorkspaceToolSelected);
window.addEventListener('verstak:navigate-back', onNavigateBack);
window.addEventListener('verstak:navigate-forward', onNavigateForward);
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
window.addEventListener('keydown', onGlobalKeydown);
window.addEventListener('pointerdown', onGlobalMouse, true);
window.addEventListener('mousedown', onGlobalMouse, true);
window.addEventListener('mouseup', onGlobalMouse, true);
window.addEventListener('auxclick', onGlobalMouse, true);
}
onMount(async () => {
await checkVault();
pushNavigation();
});
onDestroy(unsubscribeLocale);
</script>
{#if loading}
<div class="app-loading">
<p>{tr('app.loading')}</p>
</div>
{:else if needsVaultSelection}
<VaultSelection />
{:else}
<main>
<Sidebar showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'} />
<CommandPalette />
<section class="content-shell">
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
<WorkspaceHost
selectedWorkspaceName={selectedWorkspaceName}
nodes={workspaceNodes}
bind:activeToolKey={activeWorkspaceToolKey}
/>
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
</section>
<StatusBar />
</section>
</main>
{/if}
<style>
:global(*) {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:global(:root) {
--vt-color-background: #101020;
--vt-color-surface: #15152c;
--vt-color-surface-muted: #111629;
--vt-color-surface-hover: #1b2440;
--vt-color-surface-selected: rgba(78, 204, 163, 0.14);
--vt-color-border: #202b46;
--vt-color-border-strong: #2c456a;
--vt-color-text-primary: #f4f7fb;
--vt-color-text-secondary: #b7c0d4;
--vt-color-text-muted: #7f8aa3;
--vt-color-accent: #4ecca3;
--vt-color-accent-muted: rgba(78, 204, 163, 0.14);
--vt-color-danger: #e94560;
--vt-color-danger-muted: rgba(233, 69, 96, 0.14);
--vt-color-warning: #ffc857;
--vt-color-warning-muted: rgba(255, 200, 87, 0.14);
--vt-color-success: #4ecca3;
--vt-space-1: 0.25rem;
--vt-space-2: 0.5rem;
--vt-space-3: 0.75rem;
--vt-space-4: 1rem;
--vt-space-6: 1.5rem;
--vt-space-8: 2rem;
--vt-radius-sm: 4px;
--vt-radius-md: 6px;
--vt-radius-lg: 8px;
--vt-font-xs: 0.72rem;
--vt-font-sm: 0.8rem;
--vt-font-md: 0.88rem;
--vt-font-lg: 1rem;
--vt-focus-ring: 0 0 0 2px rgba(78, 204, 163, 0.34);
--vt-elevation-menu: 0 14px 32px rgba(0, 0, 0, 0.42);
}
:global(html),
:global(body),
:global(#app) {
width: 100%;
height: 100%;
}
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--vt-color-background);
color: var(--vt-color-text-primary);
overflow: hidden;
}
:global(button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 2rem;
padding: 0.4rem 0.85rem;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
font: inherit;
font-size: var(--vt-font-sm);
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
}
:global(button:hover:not(:disabled)) {
background: #203050;
border-color: var(--vt-color-accent);
color: #ffffff;
}
:global(button:focus-visible) {
outline: 0;
box-shadow: var(--vt-focus-ring);
}
:global(button:disabled) {
opacity: 0.55;
cursor: not-allowed;
}
:global(.btn-primary) {
background: var(--vt-color-accent);
border-color: var(--vt-color-accent);
color: #101827;
}
:global(.btn-primary:hover:not(:disabled)) {
background: #63d9b3;
border-color: #63d9b3;
color: #101827;
}
:global(.btn-secondary) {
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border-strong);
color: var(--vt-color-text-primary);
}
:global(.btn-danger) {
background: var(--vt-color-danger);
border-color: var(--vt-color-danger);
color: #ffffff;
}
:global(.btn-danger:hover:not(:disabled)) {
background: #ff5b73;
border-color: #ff5b73;
}
:global(.btn-ghost) {
background: transparent;
border-color: transparent;
color: var(--vt-color-text-secondary);
}
:global(.btn-ghost:hover:not(:disabled)) {
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border);
color: var(--vt-color-text-primary);
}
:global(.btn-icon) {
width: 2rem;
min-width: 2rem;
padding: 0;
}
:global(.vt-page) {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--vt-color-background);
color: var(--vt-color-text-primary);
}
:global(.vt-page-header),
:global(.vt-toolbar) {
min-height: 2.75rem;
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: var(--vt-space-2) var(--vt-space-3);
border-bottom: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
flex-shrink: 0;
}
:global(.vt-page-title) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-primary);
font-size: var(--vt-font-lg);
font-weight: 650;
}
:global(.vt-page-subtitle) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-toolbar-group) {
display: inline-flex;
align-items: center;
gap: var(--vt-space-1);
padding: 0 var(--vt-space-2) 0 0;
border-right: 1px solid var(--vt-color-border);
}
:global(.vt-toolbar-group:last-child) {
border-right: 0;
padding-right: 0;
}
:global(.vt-button) {
min-height: 2rem;
border-radius: var(--vt-radius-md);
}
:global(.vt-icon-button) {
width: 2rem;
min-width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0;
}
:global(.vt-tabbar) {
display: flex;
align-items: center;
gap: var(--vt-space-1);
padding: var(--vt-space-1) var(--vt-space-3) 0;
border-bottom: 1px solid var(--vt-color-border);
background: #12162a;
overflow-x: auto;
flex-shrink: 0;
}
:global(.vt-tab) {
flex-shrink: 0;
min-height: 2.1rem;
padding: 0.38rem 0.78rem;
border: 1px solid transparent;
border-bottom: 0;
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
background: transparent;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-tab:hover:not(:disabled)) {
background: var(--vt-color-surface-hover);
border-color: transparent;
color: var(--vt-color-text-primary);
}
:global(.vt-tab.is-active),
:global(.vt-tab.active) {
background: var(--vt-color-background);
border-color: var(--vt-color-border);
color: var(--vt-color-accent);
}
:global(.vt-list-row) {
border-bottom: 1px solid rgba(32, 43, 70, 0.72);
color: var(--vt-color-text-primary);
}
:global(.vt-list-row:hover) {
background: var(--vt-color-surface-hover);
}
:global(.vt-list-row.selected),
:global(.vt-list-row.active),
:global(.vt-list-row.is-selected) {
background: var(--vt-color-surface-selected);
box-shadow: inset 2px 0 0 var(--vt-color-accent);
}
:global(.vt-card) {
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
background: var(--vt-color-surface);
}
:global(.vt-split-pane) {
display: grid;
grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr);
min-height: 0;
height: 100%;
background: var(--vt-color-background);
}
:global(.vt-split-list) {
min-height: 0;
overflow: auto;
border-right: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
}
:global(.vt-split-detail) {
min-width: 0;
min-height: 0;
overflow: auto;
padding: var(--vt-space-4);
}
:global(.vt-empty-state) {
min-height: 9rem;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--vt-space-2);
padding: var(--vt-space-8);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
line-height: 1.45;
text-align: center;
}
:global(.vt-empty-title) {
color: var(--vt-color-text-secondary);
font-size: var(--vt-font-md);
font-weight: 650;
}
:global(.vt-inline-alert) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-secondary);
padding: var(--vt-space-3);
font-size: var(--vt-font-sm);
line-height: 1.45;
}
:global(.vt-inline-alert.error) {
border-color: rgba(233, 69, 96, 0.55);
background: var(--vt-color-danger-muted);
color: #ffc6ce;
}
:global(.vt-badge) {
display: inline-flex;
align-items: center;
min-height: 1.25rem;
padding: 0.1rem 0.4rem;
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-sm);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-xs);
font-weight: 650;
line-height: 1;
}
:global(.vt-badge.accent) {
border-color: rgba(78, 204, 163, 0.4);
background: var(--vt-color-accent-muted);
color: var(--vt-color-accent);
}
:global(.vt-menu) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface);
color: var(--vt-color-text-primary);
box-shadow: var(--vt-elevation-menu);
}
:global(.vt-menu-item) {
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: 0.42rem 0.7rem;
color: var(--vt-color-text-secondary);
cursor: pointer;
}
:global(.vt-menu-item:hover) {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
}
:global(.vt-menu-item.danger) {
color: #ff9aaa;
}
:global(.scroll-surface) {
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
:global(*) {
scrollbar-width: thin;
scrollbar-color: var(--vt-color-border-strong) var(--vt-color-background);
}
:global(*::-webkit-scrollbar) {
width: 8px;
height: 8px;
}
:global(*::-webkit-scrollbar-track) {
background: var(--vt-color-background);
}
:global(*::-webkit-scrollbar-thumb) {
background: var(--vt-color-border-strong);
border-radius: var(--vt-radius-sm);
}
:global(*::-webkit-scrollbar-thumb:hover) {
background: #1a4a7a;
}
.app-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--vt-color-background);
color: var(--vt-color-text-muted);
font-size: 1rem;
}
main {
display: flex;
height: 100vh;
width: 100%;
background: var(--vt-color-background);
overflow: hidden;
}
.content-shell {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
}
@media (max-width: 720px) {
main {
flex-direction: column;
}
.content {
padding: 0.75rem;
}
}
</style>