feat: i18n/主题、多窗口、Tauri 后端接入与终端联通、UI 重构
- 引入国际化与中英文本体系,统一多语言文案。 - 增加主题系统(dark/light/system)与全局样式能力。 - 大幅升级 Dashboard、聊天区和整体响应式布局,修复多处 UI 显示问题。 - 接入 Tauri Rust 后端基础结构(src-tauri、Cargo 配置、能力声明)。 - 打通终端桥接与用户终端联通链路,完善终端相关前后端交互。 - 增加多窗口能力与窗口管理支持。
This commit is contained in:
1 parent
b738d1f459
commit
fe289af4aa
19 files changed
+4320
-219
No files matched your search
+1
-39
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nexus Dashboard</title>
|
||||
<title>golutra</title>
|
||||
<script>
|
||||
(() => {
|
||||
const storageKey = 'nexus-theme';
|
||||
@@ -19,44 +19,6 @@
|
||||
root.classList.toggle('dark', resolvedTheme === 'dark');
|
||||
})();
|
||||
</script>
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
||||
<!-- Material Symbols -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Be Vietnam Pro"', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
background: 'rgb(var(--color-background) / <alpha-value>)',
|
||||
surface: 'rgb(var(--color-surface) / <alpha-value>)',
|
||||
panel: 'rgb(var(--color-panel) / <alpha-value>)',
|
||||
'panel-strong': 'rgb(var(--color-panel-strong) / <alpha-value>)',
|
||||
'panel-soft': 'rgb(var(--color-panel-soft) / <alpha-value>)',
|
||||
primary: 'rgb(var(--color-primary) / <alpha-value>)',
|
||||
'primary-hover': 'rgb(var(--color-primary-hover) / <alpha-value>)',
|
||||
secondary: 'rgb(var(--color-secondary) / <alpha-value>)',
|
||||
text: 'rgb(var(--color-text) / <alpha-value>)',
|
||||
muted: 'rgb(var(--color-text-muted) / <alpha-value>)',
|
||||
border: 'rgb(var(--color-border) / <alpha-value>)',
|
||||
},
|
||||
boxShadow: {
|
||||
'glow': '0 0 20px -5px rgb(var(--color-primary) / 0.45)',
|
||||
'glass': '0 8px 32px 0 rgb(0 0 0 / 0.32)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
|
||||
+14
-5
@@ -15,23 +15,32 @@
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-i18n": "^11.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^22.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"globals": "^17.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.3.3",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^2.1.9"
|
||||
"vitest": "^2.1.9",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
Generated
+885
-60
File diff suppressed because it is too large.
Load diff
@@ -21,5 +21,9 @@ tauri-build = { version = "2.5.3", features = [] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
sha2 = "0.10"
|
||||
fs2 = "0.4"
|
||||
portable-pty = "0.9.0"
|
||||
tauri = { version = "2.9.5", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-dialog = "2.6.0"
|
||||
File diff suppressed because it is too large.
Load diff
+213
-15
@@ -1,31 +1,229 @@
|
||||
<template>
|
||||
<div v-if="activeTab === 'workspaces'" class="flex h-screen w-full bg-background app-shell relative overflow-hidden">
|
||||
<WorkspaceSelection @select-workspace="activeTab = 'chat'" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-screen w-full bg-background app-shell font-sans relative overflow-hidden"
|
||||
class="window-frame"
|
||||
:class="{ 'window-frame--max': isMaximized, 'window-frame--inactive': !isFocused }"
|
||||
>
|
||||
<SidebarNav :active-tab="activeTab" @change="activeTab = $event" />
|
||||
<main class="flex-1 h-full overflow-hidden relative flex flex-col pb-16 md:pb-0">
|
||||
<SkillStore v-if="activeTab === 'store'" />
|
||||
<PluginMarketplace v-else-if="activeTab === 'plugins'" />
|
||||
<Settings v-else-if="activeTab === 'settings'" @logout="activeTab = 'workspaces'" />
|
||||
<ChatInterface v-else />
|
||||
</main>
|
||||
<header
|
||||
class="titlebar"
|
||||
:class="{ 'titlebar--mac': isMacOS }"
|
||||
data-tauri-drag-region
|
||||
@dblclick="handleToggleMaximize"
|
||||
>
|
||||
<div class="titlebar__left" data-tauri-drag-region>
|
||||
<span class="titlebar__title">{{ windowTitle }}</span>
|
||||
</div>
|
||||
<div v-if="showWindowControls" class="titlebar__controls" data-tauri-drag-region="false" @dblclick.stop>
|
||||
<button
|
||||
type="button"
|
||||
class="titlebar__btn"
|
||||
:aria-label="t('app.windowControls.minimize')"
|
||||
:title="t('app.windowControls.minimize')"
|
||||
data-tauri-drag-region="false"
|
||||
@click="handleMinimize"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" aria-hidden="true">
|
||||
<line x1="1" y1="5" x2="9" y2="5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="titlebar__btn"
|
||||
:aria-label="t('app.windowControls.maximize')"
|
||||
:title="t('app.windowControls.maximize')"
|
||||
data-tauri-drag-region="false"
|
||||
@click="handleToggleMaximize"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" aria-hidden="true">
|
||||
<rect x="2" y="2" width="6" height="6" fill="none" stroke="currentColor" stroke-width="1.2" rx="0.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="titlebar__btn titlebar__btn--close"
|
||||
:aria-label="t('app.windowControls.close')"
|
||||
:title="t('app.windowControls.close')"
|
||||
data-tauri-drag-region="false"
|
||||
@click="handleClose"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" aria-hidden="true">
|
||||
<line x1="2" y1="2" x2="8" y2="8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
<line x1="8" y1="2" x2="2" y2="8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="window-body">
|
||||
<div v-if="isTerminalView" class="flex h-full w-full bg-background app-shell relative overflow-hidden">
|
||||
<TerminalWorkspace />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showWorkspaceSelection"
|
||||
class="flex h-full w-full bg-background app-shell relative overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<WorkspaceSelection />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!appReady"
|
||||
class="flex h-full w-full bg-background app-shell relative overflow-hidden items-center justify-center"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3 text-white/70 text-sm">
|
||||
<div class="h-10 w-10 rounded-full border border-white/20 border-t-transparent animate-spin"></div>
|
||||
<span>Loading workspace...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full bg-background app-shell font-sans relative overflow-hidden"
|
||||
>
|
||||
<SidebarNav :active-tab="activeTab" @change="setActiveTab($event)" />
|
||||
<main class="flex-1 h-full overflow-hidden relative flex flex-col pb-16 md:pb-0">
|
||||
<SkillStore v-if="activeTab === 'store'" />
|
||||
<PluginMarketplace v-else-if="activeTab === 'plugins'" />
|
||||
<Settings
|
||||
v-else-if="activeTab === 'settings'"
|
||||
@logout="setActiveTab('workspaces')"
|
||||
/>
|
||||
<ChatInterface v-else />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<ToastStack />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import SidebarNav from '@/shared/components/SidebarNav.vue';
|
||||
import ToastStack from '@/shared/components/ToastStack.vue';
|
||||
import SkillStore from '@/features/SkillStore.vue';
|
||||
import PluginMarketplace from '@/features/PluginMarketplace.vue';
|
||||
import TerminalWorkspace from '@/features/terminal/TerminalWorkspace.vue';
|
||||
import Settings from '@/features/Settings.vue';
|
||||
import WorkspaceSelection from '@/features/WorkspaceSelection.vue';
|
||||
import ChatInterface from '@/features/chat/ChatInterface.vue';
|
||||
import { useWorkspaceStore } from '@/features/workspace/workspaceStore';
|
||||
import { useGlobalStore } from '@/features/global/globalStore';
|
||||
import { useNavigationStore } from '@/stores/navigationStore';
|
||||
import { useWorkspaceBootstrap } from './useWorkspaceBootstrap';
|
||||
import { isTauri } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
type TabId = 'workspaces' | 'chat' | 'store' | 'plugins' | 'settings';
|
||||
const navigationStore = useNavigationStore();
|
||||
const { activeTab } = storeToRefs(navigationStore);
|
||||
const { setActiveTab } = navigationStore;
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const { currentWorkspace } = storeToRefs(workspaceStore);
|
||||
const showWorkspaceSelection = computed(() => activeTab.value === 'workspaces' || !currentWorkspace.value);
|
||||
const { appReady } = useWorkspaceBootstrap();
|
||||
const globalStore = useGlobalStore();
|
||||
void globalStore.hydrate();
|
||||
const { t } = useI18n();
|
||||
const resolvedView =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('view') ??
|
||||
(window as typeof window & { __NEXUS_VIEW__?: string }).__NEXUS_VIEW__
|
||||
: null;
|
||||
const isTerminalView = resolvedView === 'terminal';
|
||||
const isTauriEnv = isTauri();
|
||||
const APP_NAME = 'golutra';
|
||||
const isMacOS = computed(() => typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
|
||||
const showWindowControls = computed(() => isTauriEnv && !isMacOS.value);
|
||||
const contextTitle = computed(() => {
|
||||
if (isTerminalView) {
|
||||
const name = currentWorkspace.value?.name?.trim();
|
||||
return name ? `${name} Terminal` : 'Terminal';
|
||||
}
|
||||
if (showWorkspaceSelection.value) return 'Workspaces';
|
||||
if (!appReady.value) return 'Loading';
|
||||
const workspaceName = currentWorkspace.value?.name?.trim();
|
||||
return workspaceName || 'Home';
|
||||
});
|
||||
const windowTitle = computed(() => `${contextTitle.value} - ${APP_NAME}`);
|
||||
const appWindow = isTauriEnv ? getCurrentWindow() : null;
|
||||
const isMaximized = ref(false);
|
||||
const isFocused = ref(true);
|
||||
let unlistenResize: (() => void) | null = null;
|
||||
let unlistenFocus: (() => void) | null = null;
|
||||
|
||||
const activeTab = ref<TabId>('workspaces');
|
||||
const refreshMaximized = async () => {
|
||||
if (!appWindow) return;
|
||||
try {
|
||||
isMaximized.value = await appWindow.isMaximized();
|
||||
} catch {
|
||||
isMaximized.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinimize = () => {
|
||||
if (!appWindow) return;
|
||||
void appWindow.minimize();
|
||||
};
|
||||
|
||||
const handleToggleMaximize = () => {
|
||||
if (!appWindow) return;
|
||||
void appWindow.toggleMaximize();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!appWindow) return;
|
||||
void appWindow.close();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => currentWorkspace.value,
|
||||
(workspace) => {
|
||||
if (!workspace) {
|
||||
setActiveTab('workspaces');
|
||||
return;
|
||||
}
|
||||
if (activeTab.value === 'workspaces') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
windowTitle,
|
||||
(title) => {
|
||||
if (!appWindow) return;
|
||||
appWindow.setTitle(title).catch(() => {});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!appWindow) return;
|
||||
void refreshMaximized();
|
||||
appWindow
|
||||
.onResized(() => {
|
||||
void refreshMaximized();
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenResize = unlisten;
|
||||
})
|
||||
.catch(() => {});
|
||||
appWindow
|
||||
.onFocusChanged((event) => {
|
||||
isFocused.value = event.payload;
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenFocus = unlisten;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unlistenResize) {
|
||||
unlistenResize();
|
||||
unlistenResize = null;
|
||||
}
|
||||
if (unlistenFocus) {
|
||||
unlistenFocus();
|
||||
unlistenFocus = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="h-full w-full bg-[#0b0f14]">
|
||||
<div ref="terminalRef" class="h-full w-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { getSessionHistory, resizeSession, subscribeExit, subscribeOutput, writeSession } from './terminalBridge';
|
||||
|
||||
const props = defineProps<{ sessionId: string; active: boolean }>();
|
||||
const sessionId = props.sessionId;
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null);
|
||||
const rootRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
let webglAddon: WebglAddon | null = null;
|
||||
let terminal: Terminal | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let unsubscribeOutput: (() => void) | null = null;
|
||||
let unsubscribeExit: (() => void) | null = null;
|
||||
let mouseUpHandler: ((event: MouseEvent) => void) | null = null;
|
||||
let refreshRaf: number | null = null;
|
||||
let refreshRafTail: number | null = null;
|
||||
let historyReady = false;
|
||||
|
||||
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||
let lastCopiedSelection = '';
|
||||
|
||||
const copySelection = (options?: { force?: boolean; clear?: boolean }) => {
|
||||
if (!terminal || !terminal.hasSelection()) {
|
||||
return;
|
||||
}
|
||||
const selection = terminal.getSelection();
|
||||
if (!selection || selection === lastCopiedSelection) {
|
||||
if (options?.force && selection) {
|
||||
void navigator.clipboard.writeText(selection).catch(() => {});
|
||||
if (options?.clear) {
|
||||
terminal.clearSelection();
|
||||
terminal.focus();
|
||||
lastCopiedSelection = '';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastCopiedSelection = selection;
|
||||
void navigator.clipboard.writeText(selection).catch(() => {});
|
||||
if (options?.clear) {
|
||||
terminal.clearSelection();
|
||||
terminal.focus();
|
||||
lastCopiedSelection = '';
|
||||
}
|
||||
};
|
||||
|
||||
const attachClipboardHandlers = (root: HTMLElement) => {
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const ctrlKey = isMac ? event.metaKey : event.ctrlKey;
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
if (ctrlKey && key === 'c') {
|
||||
if (terminal.hasSelection()) {
|
||||
copySelection({ force: true, clear: true });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
mouseUpHandler = (event) => {
|
||||
if (event.button === 0) {
|
||||
copySelection();
|
||||
}
|
||||
};
|
||||
root.addEventListener('mouseup', mouseUpHandler);
|
||||
};
|
||||
|
||||
const fitTerminal = () => {
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
fitAddon.fit();
|
||||
if (terminal.cols > 0 && terminal.rows > 0) {
|
||||
void resizeSession(sessionId, terminal.cols, terminal.rows).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleWebglRefresh = () => {
|
||||
if (!terminal || !webglAddon) {
|
||||
return;
|
||||
}
|
||||
if (refreshRaf !== null || refreshRafTail !== null) {
|
||||
return;
|
||||
}
|
||||
refreshRaf = window.requestAnimationFrame(() => {
|
||||
refreshRaf = null;
|
||||
refreshRafTail = window.requestAnimationFrame(() => {
|
||||
refreshRafTail = null;
|
||||
if (!terminal || !webglAddon) {
|
||||
return;
|
||||
}
|
||||
if (terminal.rows <= 0 || terminal.cols <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
terminal.clearTextureAtlas();
|
||||
} catch {}
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const attachOutput = () => {
|
||||
if (!terminal || unsubscribeOutput) {
|
||||
return;
|
||||
}
|
||||
unsubscribeOutput = subscribeOutput(sessionId, (data) => {
|
||||
terminal?.write(data);
|
||||
});
|
||||
unsubscribeExit = subscribeExit(sessionId, (payload) => {
|
||||
const reason = payload.signal ? `signal ${payload.signal}` : `code ${payload.code ?? 'unknown'}`;
|
||||
terminal?.writeln(`\r\n[process exited: ${reason}]`);
|
||||
});
|
||||
};
|
||||
|
||||
const detachOutput = () => {
|
||||
unsubscribeOutput?.();
|
||||
unsubscribeOutput = null;
|
||||
unsubscribeExit?.();
|
||||
unsubscribeExit = null;
|
||||
};
|
||||
|
||||
const applyActiveState = (isActive: boolean) => {
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
terminal.options.disableStdin = !isActive;
|
||||
if (!historyReady) {
|
||||
if (!isActive) {
|
||||
detachOutput();
|
||||
resizeObserver?.disconnect();
|
||||
terminal.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
attachOutput();
|
||||
if (rootRef.value && resizeObserver) {
|
||||
resizeObserver.observe(rootRef.value);
|
||||
}
|
||||
void nextTick(() => {
|
||||
fitTerminal();
|
||||
scheduleWebglRefresh();
|
||||
terminal?.focus();
|
||||
});
|
||||
} else {
|
||||
detachOutput();
|
||||
resizeObserver?.disconnect();
|
||||
terminal.blur();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!terminalRef.value || !rootRef.value) {
|
||||
return;
|
||||
}
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily:
|
||||
"'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
|
||||
fontSize: 13,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: '#0b0f14',
|
||||
foreground: '#e5e7eb',
|
||||
cursor: '#38bdf8'
|
||||
}
|
||||
});
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(terminalRef.value);
|
||||
await nextTick();
|
||||
if ('fonts' in document) {
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch {}
|
||||
}
|
||||
fitTerminal();
|
||||
try {
|
||||
webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon?.dispose();
|
||||
webglAddon = null;
|
||||
});
|
||||
terminal.loadAddon(webglAddon);
|
||||
window.setTimeout(() => {
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
terminal.clearTextureAtlas();
|
||||
} catch {}
|
||||
fitTerminal();
|
||||
}, 50);
|
||||
} catch {
|
||||
webglAddon?.dispose();
|
||||
webglAddon = null;
|
||||
}
|
||||
attachClipboardHandlers(rootRef.value);
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (!props.active) {
|
||||
return;
|
||||
}
|
||||
void writeSession(sessionId, data).catch((error) => {
|
||||
terminal?.writeln(`\r\n[terminal error] ${String(error)}`);
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (props.active) {
|
||||
fitTerminal();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const history = await getSessionHistory(sessionId);
|
||||
if (history) {
|
||||
terminal?.write(history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load terminal history.', error);
|
||||
}
|
||||
historyReady = true;
|
||||
applyActiveState(props.active);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(isActive) => {
|
||||
applyActiveState(isActive);
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
detachOutput();
|
||||
if (rootRef.value && mouseUpHandler) {
|
||||
rootRef.value.removeEventListener('mouseup', mouseUpHandler);
|
||||
}
|
||||
if (refreshRaf !== null) {
|
||||
window.cancelAnimationFrame(refreshRaf);
|
||||
refreshRaf = null;
|
||||
}
|
||||
if (refreshRafTail !== null) {
|
||||
window.cancelAnimationFrame(refreshRafTail);
|
||||
refreshRafTail = null;
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
webglAddon?.dispose();
|
||||
webglAddon = null;
|
||||
terminal?.dispose();
|
||||
terminal = null;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<section class="flex h-full w-full flex-col overflow-hidden">
|
||||
<header class="flex items-center justify-between px-6 py-4 border-b border-white/5 bg-panel/60 backdrop-blur">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-white">{{ t('terminal.title') }}</h1>
|
||||
<p class="text-xs text-white/40">{{ t('terminal.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-semibold uppercase tracking-wide text-white/80 border border-white/10 bg-white/5 hover:bg-white/10 hover:text-white transition"
|
||||
@click="handleNewTab"
|
||||
>
|
||||
<span class="material-symbols-outlined text-[18px]">add</span>
|
||||
{{ t('terminal.newTab') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex items-center gap-2 px-6 py-3 border-b border-white/5 bg-surface/30 overflow-x-auto">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(tab.id, $event)"
|
||||
@dragover="onDragOver(tab.id, $event)"
|
||||
@drop="onDrop(tab.id)"
|
||||
@dragend="onDragEnd"
|
||||
@click="setActive(tab.id)"
|
||||
:class="[
|
||||
'group flex items-center gap-2 px-3 py-1.5 rounded-lg border transition whitespace-nowrap',
|
||||
tab.id === activeId
|
||||
? 'bg-white/10 border-white/30 text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/60 hover:text-white hover:border-white/20',
|
||||
dragOverId === tab.id && dragId !== tab.id ? 'ring-1 ring-primary/60' : ''
|
||||
]"
|
||||
>
|
||||
<span class="material-symbols-outlined text-[16px]">terminal</span>
|
||||
<span class="text-xs font-semibold">{{ tab.title }}</span>
|
||||
<span v-if="tab.hasActivity" class="ml-1 w-2 h-2 rounded-full bg-primary shadow-glow"></span>
|
||||
<span
|
||||
class="material-symbols-outlined text-[14px] text-white/40 hover:text-white"
|
||||
@click.stop="closeTab(tab.id)"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<span v-if="tabs.length === 0" class="text-xs text-white/40">
|
||||
{{ t('terminal.emptyTabs') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div v-if="tabs.length === 0" class="h-full flex flex-col items-center justify-center text-center text-white/50">
|
||||
<div class="w-14 h-14 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
|
||||
<span class="material-symbols-outlined text-[28px]">terminal</span>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-white/70">{{ t('terminal.emptyTitle') }}</p>
|
||||
<p class="text-xs text-white/40 mt-1">{{ t('terminal.emptySubtitle') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold uppercase tracking-wide text-white bg-primary hover:bg-primary-hover shadow-glow transition"
|
||||
@click="handleNewTab"
|
||||
>
|
||||
<span class="material-symbols-outlined text-[18px]">add</span>
|
||||
{{ t('terminal.newTab') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="h-full w-full">
|
||||
<TerminalPane
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:session-id="tab.id"
|
||||
:active="tab.id === activeId"
|
||||
v-show="tab.id === activeId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import TerminalPane from './TerminalPane.vue';
|
||||
import { onActivity } from './terminalBridge';
|
||||
import { useTerminalStore } from './terminalStore';
|
||||
import {
|
||||
TERMINAL_OPEN_TAB_EVENT,
|
||||
TERMINAL_WINDOW_READY_EVENT,
|
||||
TERMINAL_WINDOW_READY_REQUEST_EVENT,
|
||||
type TerminalOpenTabPayload
|
||||
} from './terminalEvents';
|
||||
import { useToastStore } from '@/stores/toastStore';
|
||||
import { useProjectStore } from '@/features/workspace/projectStore';
|
||||
|
||||
const { t } = useI18n();
|
||||
const toastStore = useToastStore();
|
||||
const { pushToast } = toastStore;
|
||||
const terminalStore = useTerminalStore();
|
||||
const { tabs, activeId } = storeToRefs(terminalStore);
|
||||
const { createTab, setActive, closeTab, moveTab, markActivity, clearActivity, openTab } = terminalStore;
|
||||
const projectStore = useProjectStore();
|
||||
const { members } = storeToRefs(projectStore);
|
||||
|
||||
const dragId = ref<string | null>(null);
|
||||
const dragOverId = ref<string | null>(null);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const resolveTabTitle = (memberId: string | undefined, payloadTitle: string) => {
|
||||
if (memberId) {
|
||||
const memberName = members.value.find((member) => member.id === memberId)?.name?.trim();
|
||||
if (memberName) {
|
||||
return memberName;
|
||||
}
|
||||
}
|
||||
const fallbackTitle = payloadTitle.trim();
|
||||
return fallbackTitle || payloadTitle;
|
||||
};
|
||||
|
||||
const handleNewTab = async () => {
|
||||
if (isCreating.value) {
|
||||
return;
|
||||
}
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await createTab();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('terminal buffer limit reached')) {
|
||||
pushToast(t('terminal.resourceLimit'), { tone: 'error' });
|
||||
} else {
|
||||
console.error('Failed to start terminal.', error);
|
||||
}
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragStart = (id: string, event: DragEvent) => {
|
||||
dragId.value = id;
|
||||
dragOverId.value = null;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', id);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (id: string, event: DragEvent) => {
|
||||
if (!dragId.value || dragId.value === id) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragOverId.value = id;
|
||||
};
|
||||
|
||||
const onDrop = (id: string) => {
|
||||
if (!dragId.value || dragId.value === id) {
|
||||
dragOverId.value = null;
|
||||
dragId.value = null;
|
||||
return;
|
||||
}
|
||||
moveTab(dragId.value, id);
|
||||
dragOverId.value = null;
|
||||
dragId.value = null;
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragOverId.value = null;
|
||||
dragId.value = null;
|
||||
};
|
||||
|
||||
const stopActivity = onActivity((sessionId) => {
|
||||
markActivity(sessionId);
|
||||
});
|
||||
let stopOpenTab: (() => void) | null = null;
|
||||
let stopReadyRequest: (() => void) | null = null;
|
||||
let autoTabTimer: number | null = null;
|
||||
|
||||
const emitWindowReady = () => {
|
||||
try {
|
||||
const windowLabel = getCurrentWebviewWindow().label;
|
||||
void emit(TERMINAL_WINDOW_READY_EVENT, { windowLabel });
|
||||
} catch {
|
||||
// No-op when running outside a Tauri window.
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeId, (next) => {
|
||||
if (next) {
|
||||
clearActivity(next);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
members,
|
||||
(nextMembers) => {
|
||||
const memberMap = new Map(nextMembers.map((member) => [member.id, member.name]));
|
||||
for (const tab of tabs.value) {
|
||||
if (!tab.memberId) {
|
||||
continue;
|
||||
}
|
||||
const memberName = memberMap.get(tab.memberId)?.trim();
|
||||
if (memberName && tab.title !== memberName) {
|
||||
tab.title = memberName;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const init = async () => {
|
||||
try {
|
||||
stopOpenTab = await listen<TerminalOpenTabPayload>(TERMINAL_OPEN_TAB_EVENT, (event) => {
|
||||
const { sessionId, title, memberId, keepAlive } = event.payload;
|
||||
const resolvedTitle = resolveTabTitle(memberId, title);
|
||||
openTab(sessionId, { title: resolvedTitle, memberId, keepAlive });
|
||||
if (autoTabTimer !== null) {
|
||||
window.clearTimeout(autoTabTimer);
|
||||
autoTabTimer = null;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to listen for terminal tabs.', error);
|
||||
}
|
||||
|
||||
try {
|
||||
stopReadyRequest = await listen(TERMINAL_WINDOW_READY_REQUEST_EVENT, () => {
|
||||
emitWindowReady();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to listen for terminal ready requests.', error);
|
||||
}
|
||||
|
||||
emitWindowReady();
|
||||
|
||||
if (tabs.value.length === 0) {
|
||||
autoTabTimer = window.setTimeout(() => {
|
||||
if (tabs.value.length === 0) {
|
||||
void handleNewTab();
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
await init();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopActivity();
|
||||
stopOpenTab?.();
|
||||
stopOpenTab = null;
|
||||
stopReadyRequest?.();
|
||||
stopReadyRequest = null;
|
||||
if (autoTabTimer !== null) {
|
||||
window.clearTimeout(autoTabTimer);
|
||||
autoTabTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
type OutputPayload = { sessionId: string; data: string };
|
||||
type ExitPayload = { sessionId: string; code?: number | null; signal?: string | null };
|
||||
type StatusPayload = { sessionId: string; status: string; memberId?: string; workspaceId?: string };
|
||||
|
||||
type OutputListener = (data: string) => void;
|
||||
type ExitListener = (payload: ExitPayload) => void;
|
||||
type ActivityListener = (sessionId: string) => void;
|
||||
type StatusListener = (payload: StatusPayload) => void;
|
||||
|
||||
const outputListeners = new Map<string, Set<OutputListener>>();
|
||||
const exitListeners = new Map<string, Set<ExitListener>>();
|
||||
const activityListeners = new Set<ActivityListener>();
|
||||
const statusListeners = new Set<StatusListener>();
|
||||
const buffers = new Map<string, string[]>();
|
||||
|
||||
const BUFFER_LIMIT = 2000;
|
||||
let initialized = false;
|
||||
|
||||
const pushBuffer = (sessionId: string, data: string) => {
|
||||
const queue = buffers.get(sessionId) ?? [];
|
||||
queue.push(data);
|
||||
if (queue.length > BUFFER_LIMIT) {
|
||||
queue.splice(0, queue.length - BUFFER_LIMIT);
|
||||
}
|
||||
buffers.set(sessionId, queue);
|
||||
};
|
||||
|
||||
const flushBuffer = (sessionId: string, handler: OutputListener) => {
|
||||
const queue = buffers.get(sessionId);
|
||||
if (!queue || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
handler(queue.join(''));
|
||||
buffers.delete(sessionId);
|
||||
};
|
||||
|
||||
const ensureListeners = async () => {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
await listen<OutputPayload>('terminal-output', (event) => {
|
||||
const { sessionId, data } = event.payload;
|
||||
const listeners = outputListeners.get(sessionId);
|
||||
if (listeners && listeners.size > 0) {
|
||||
for (const handler of listeners) {
|
||||
handler(data);
|
||||
}
|
||||
} else {
|
||||
pushBuffer(sessionId, data);
|
||||
}
|
||||
for (const handler of activityListeners) {
|
||||
handler(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
await listen<ExitPayload>('terminal-exit', (event) => {
|
||||
const { sessionId } = event.payload;
|
||||
const listeners = exitListeners.get(sessionId);
|
||||
if (!listeners || listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const handler of listeners) {
|
||||
handler(event.payload);
|
||||
}
|
||||
});
|
||||
|
||||
await listen<StatusPayload>('terminal-status-change', (event) => {
|
||||
for (const handler of statusListeners) {
|
||||
handler(event.payload);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createSession = async (options?: {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cwd?: string;
|
||||
memberId?: string;
|
||||
workspaceId?: string;
|
||||
keepAlive?: boolean;
|
||||
sessionId?: string;
|
||||
initialData?: string;
|
||||
}) => {
|
||||
await ensureListeners();
|
||||
return invoke<string>('terminal_create', {
|
||||
cols: options?.cols,
|
||||
rows: options?.rows,
|
||||
cwd: options?.cwd,
|
||||
memberId: options?.memberId,
|
||||
workspaceId: options?.workspaceId,
|
||||
keepAlive: options?.keepAlive,
|
||||
sessionId: options?.sessionId,
|
||||
initialData: options?.initialData
|
||||
});
|
||||
};
|
||||
|
||||
export const writeSession = async (sessionId: string, data: string) => {
|
||||
await ensureListeners();
|
||||
return invoke('terminal_write', { sessionId, data });
|
||||
};
|
||||
|
||||
export const resizeSession = async (sessionId: string, cols: number, rows: number) => {
|
||||
await ensureListeners();
|
||||
return invoke('terminal_resize', { sessionId, cols, rows });
|
||||
};
|
||||
|
||||
export const closeSession = async (sessionId: string, options?: { preserve?: boolean }) => {
|
||||
await ensureListeners();
|
||||
buffers.delete(sessionId);
|
||||
outputListeners.delete(sessionId);
|
||||
exitListeners.delete(sessionId);
|
||||
return invoke('terminal_close', { sessionId, preserve: options?.preserve });
|
||||
};
|
||||
|
||||
export const getSessionHistory = async (sessionId: string) => {
|
||||
await ensureListeners();
|
||||
buffers.delete(sessionId);
|
||||
return invoke<string>('get_session_history', { sessionId });
|
||||
};
|
||||
|
||||
export const subscribeOutput = (sessionId: string, handler: OutputListener) => {
|
||||
void ensureListeners();
|
||||
const listeners = outputListeners.get(sessionId) ?? new Set<OutputListener>();
|
||||
listeners.add(handler);
|
||||
outputListeners.set(sessionId, listeners);
|
||||
flushBuffer(sessionId, handler);
|
||||
return () => {
|
||||
const current = outputListeners.get(sessionId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.delete(handler);
|
||||
if (current.size === 0) {
|
||||
outputListeners.delete(sessionId);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const subscribeExit = (sessionId: string, handler: ExitListener) => {
|
||||
void ensureListeners();
|
||||
const listeners = exitListeners.get(sessionId) ?? new Set<ExitListener>();
|
||||
listeners.add(handler);
|
||||
exitListeners.set(sessionId, listeners);
|
||||
return () => {
|
||||
const current = exitListeners.get(sessionId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.delete(handler);
|
||||
if (current.size === 0) {
|
||||
exitListeners.delete(sessionId);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const onActivity = (handler: ActivityListener) => {
|
||||
activityListeners.add(handler);
|
||||
return () => activityListeners.delete(handler);
|
||||
};
|
||||
|
||||
export const onStatusChange = (handler: StatusListener) => {
|
||||
void ensureListeners();
|
||||
statusListeners.add(handler);
|
||||
return () => statusListeners.delete(handler);
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore, storeToRefs } from 'pinia';
|
||||
import { emitTo, listen } from '@tauri-apps/api/event';
|
||||
import type { Member, MemberStatus } from '@/features/chat/types';
|
||||
import { useProjectStore } from '@/features/workspace/projectStore';
|
||||
import { useWorkspaceStore } from '@/features/workspace/workspaceStore';
|
||||
import { openTerminalWindow } from './openTerminalWindow';
|
||||
import { closeSession, createSession, onStatusChange } from './terminalBridge';
|
||||
import {
|
||||
TERMINAL_OPEN_TAB_EVENT,
|
||||
TERMINAL_WINDOW_READY_EVENT,
|
||||
TERMINAL_WINDOW_READY_REQUEST_EVENT,
|
||||
type TerminalOpenTabPayload,
|
||||
type TerminalWindowReadyPayload
|
||||
} from './terminalEvents';
|
||||
|
||||
type MemberTerminalSession = {
|
||||
memberId: string;
|
||||
sessionId: string;
|
||||
command: string;
|
||||
title: string;
|
||||
workspaceId: string;
|
||||
status: MemberStatus;
|
||||
};
|
||||
|
||||
const buildCommandInput = (command: string) => `${command}\r`;
|
||||
const resolveTitle = (value?: string, fallback?: string) => {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const fallbackTrimmed = fallback?.trim();
|
||||
return fallbackTrimmed ?? '';
|
||||
};
|
||||
|
||||
const readyWindowLabels = new Set<string>();
|
||||
const pendingTabs = new Map<string, TerminalOpenTabPayload[]>();
|
||||
let readyListenerInitialized = false;
|
||||
let statusListenerInitialized = false;
|
||||
|
||||
const queuePendingTab = (windowLabel: string, payload: TerminalOpenTabPayload) => {
|
||||
const list = pendingTabs.get(windowLabel) ?? [];
|
||||
list.push(payload);
|
||||
pendingTabs.set(windowLabel, list);
|
||||
};
|
||||
|
||||
const flushPendingTabs = async (windowLabel: string) => {
|
||||
const list = pendingTabs.get(windowLabel);
|
||||
if (!list || list.length === 0) {
|
||||
return;
|
||||
}
|
||||
pendingTabs.delete(windowLabel);
|
||||
for (const payload of list) {
|
||||
await emitTo(windowLabel, TERMINAL_OPEN_TAB_EVENT, payload);
|
||||
}
|
||||
};
|
||||
|
||||
export const useTerminalMemberStore = defineStore('terminal-member', () => {
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const { currentWorkspace } = storeToRefs(workspaceStore);
|
||||
const projectStore = useProjectStore();
|
||||
const { updateMember } = projectStore;
|
||||
const memberSessions = ref<Record<string, MemberTerminalSession>>({});
|
||||
|
||||
const buildMemberKey = (memberId: string, workspaceId?: string) =>
|
||||
workspaceId ? `${workspaceId}:${memberId}` : memberId;
|
||||
|
||||
const ensureReadyListener = () => {
|
||||
if (readyListenerInitialized) {
|
||||
return;
|
||||
}
|
||||
readyListenerInitialized = true;
|
||||
void listen<TerminalWindowReadyPayload>(TERMINAL_WINDOW_READY_EVENT, (event) => {
|
||||
const label = event.payload.windowLabel;
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
readyWindowLabels.add(label);
|
||||
void flushPendingTabs(label);
|
||||
});
|
||||
};
|
||||
|
||||
const ensureStatusListener = () => {
|
||||
if (statusListenerInitialized) {
|
||||
return;
|
||||
}
|
||||
statusListenerInitialized = true;
|
||||
onStatusChange((payload) => {
|
||||
const workspaceId = currentWorkspace.value?.id;
|
||||
if (!payload.memberId || !payload.workspaceId || payload.workspaceId !== workspaceId) {
|
||||
return;
|
||||
}
|
||||
const status = payload.status as MemberStatus;
|
||||
if (!['online', 'working', 'dnd', 'offline'].includes(status)) {
|
||||
return;
|
||||
}
|
||||
const entry = Object.values(memberSessions.value).find((item) => item.sessionId === payload.sessionId);
|
||||
if (entry) {
|
||||
entry.status = status;
|
||||
}
|
||||
void updateMember(payload.memberId, { status });
|
||||
});
|
||||
};
|
||||
|
||||
const getSession = (memberId: string, workspaceId?: string) => {
|
||||
const entry = memberSessions.value[buildMemberKey(memberId, workspaceId)];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (workspaceId && entry.workspaceId !== workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const openMemberTab = async (entry: MemberTerminalSession, titleOverride?: string) => {
|
||||
const windowLabel = await ensureTerminalWindow();
|
||||
if (!windowLabel) {
|
||||
return;
|
||||
}
|
||||
const payload: TerminalOpenTabPayload = {
|
||||
sessionId: entry.sessionId,
|
||||
title: resolveTitle(titleOverride, entry.title),
|
||||
memberId: entry.memberId,
|
||||
keepAlive: true
|
||||
};
|
||||
ensureReadyListener();
|
||||
if (!readyWindowLabels.has(windowLabel)) {
|
||||
queuePendingTab(windowLabel, payload);
|
||||
}
|
||||
await emitTo(windowLabel, TERMINAL_OPEN_TAB_EVENT, payload).catch(() => {});
|
||||
};
|
||||
|
||||
const ensureTerminalWindow = async () => {
|
||||
ensureReadyListener();
|
||||
const workspace = currentWorkspace.value;
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
const result = await openTerminalWindow({ workspaceId: workspace.id, workspaceName: workspace.name });
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
if (!result.reused) {
|
||||
readyWindowLabels.delete(result.label);
|
||||
}
|
||||
if (!readyWindowLabels.has(result.label)) {
|
||||
void emitTo(result.label, TERMINAL_WINDOW_READY_REQUEST_EVENT, {}).catch(() => {});
|
||||
}
|
||||
return result.label;
|
||||
};
|
||||
|
||||
const startMemberSession = async (member: Member, options?: { openTab?: boolean }) => {
|
||||
const workspace = currentWorkspace.value;
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
const command = member.terminalCommand?.trim();
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
const resolvedTitle = resolveTitle(member.name, member.id);
|
||||
const requestedSessionId = member.name.trim() || undefined;
|
||||
ensureStatusListener();
|
||||
const sessionId = await createSession({
|
||||
cwd: workspace.path,
|
||||
memberId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
keepAlive: true,
|
||||
sessionId: requestedSessionId,
|
||||
initialData: buildCommandInput(command)
|
||||
});
|
||||
const entry: MemberTerminalSession = {
|
||||
memberId: member.id,
|
||||
sessionId,
|
||||
command,
|
||||
title: resolvedTitle,
|
||||
workspaceId: workspace.id,
|
||||
status: 'online'
|
||||
};
|
||||
memberSessions.value[buildMemberKey(member.id, workspace.id)] = entry;
|
||||
if (options?.openTab ?? true) {
|
||||
await openMemberTab(entry, resolvedTitle);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const ensureMemberSession = async (member: Member, options?: { openTab?: boolean }) => {
|
||||
ensureStatusListener();
|
||||
const workspaceId = currentWorkspace.value?.id;
|
||||
const existing = getSession(member.id, workspaceId);
|
||||
if (existing && existing.status !== 'offline') {
|
||||
if (options?.openTab ?? true) {
|
||||
await openMemberTab(existing, member.name);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
if (existing) {
|
||||
try {
|
||||
await closeSession(existing.sessionId, { preserve: false });
|
||||
} catch {
|
||||
// Ignore cleanup errors when restarting a session.
|
||||
}
|
||||
delete memberSessions.value[buildMemberKey(member.id, workspaceId)];
|
||||
}
|
||||
return startMemberSession(member, options);
|
||||
};
|
||||
|
||||
const openMemberTerminal = async (member: Member) => {
|
||||
ensureStatusListener();
|
||||
const workspaceId = currentWorkspace.value?.id;
|
||||
const entry = getSession(member.id, workspaceId);
|
||||
if (entry) {
|
||||
await openMemberTab(entry, member.name);
|
||||
return entry;
|
||||
}
|
||||
return startMemberSession(member, { openTab: true });
|
||||
};
|
||||
|
||||
const stopMemberSession = async (memberId: string, options?: { preserve?: boolean }) => {
|
||||
const workspaceId = currentWorkspace.value?.id;
|
||||
const entry = getSession(memberId, workspaceId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
await closeSession(entry.sessionId, { preserve: options?.preserve ?? true });
|
||||
if (options?.preserve ?? true) {
|
||||
entry.status = 'offline';
|
||||
return;
|
||||
}
|
||||
delete memberSessions.value[buildMemberKey(memberId, workspaceId)];
|
||||
};
|
||||
|
||||
return {
|
||||
ensureMemberSession,
|
||||
openMemberTerminal,
|
||||
stopMemberSession,
|
||||
getSession
|
||||
};
|
||||
});
|
||||
+96
-48
@@ -1,21 +1,40 @@
|
||||
export default {
|
||||
app: {
|
||||
name: 'Nexus Dashboard'
|
||||
name: 'golutra',
|
||||
windowControls: {
|
||||
minimize: 'Minimize',
|
||||
maximize: 'Maximize',
|
||||
close: 'Close'
|
||||
}
|
||||
},
|
||||
nav: {
|
||||
chat: 'Chat & Modals',
|
||||
terminal: 'Terminal',
|
||||
workspaces: 'Workspaces',
|
||||
store: 'Skill Store',
|
||||
plugins: 'Plugins',
|
||||
settings: 'Settings'
|
||||
},
|
||||
terminal: {
|
||||
title: 'Terminal',
|
||||
subtitle: 'Run multiple shells in one workspace.',
|
||||
newTab: 'New Tab',
|
||||
emptyTabs: 'No terminals yet.',
|
||||
emptyTitle: 'No active terminals',
|
||||
emptySubtitle: 'Create a new terminal tab to get started.',
|
||||
unavailableTitle: 'Terminal unavailable',
|
||||
unavailableSubtitle: 'Open this view inside the Tauri desktop app to use terminals.',
|
||||
errorTitle: 'Terminal failed to start',
|
||||
errorSubtitle: 'Check the desktop runtime and try again.',
|
||||
resourceLimit: 'System resources are low. Close some background terminals and try again.'
|
||||
},
|
||||
common: {
|
||||
userAvatarAlt: 'User',
|
||||
remove: 'Remove'
|
||||
},
|
||||
chat: {
|
||||
channelName: 'design-critique',
|
||||
channelDisplay: '#design-critique',
|
||||
channelName: 'general',
|
||||
channelDisplay: '#general',
|
||||
channelDescription: '',
|
||||
directMessageDescription: 'Direct message with {name}',
|
||||
header: {
|
||||
@@ -35,7 +54,7 @@ export default {
|
||||
}
|
||||
},
|
||||
sidebar: {
|
||||
workspaceName: 'Frontend Squad',
|
||||
workspaceName: 'Workspace',
|
||||
channels: 'Channels',
|
||||
directMessages: 'Direct Messages',
|
||||
channelList: {
|
||||
@@ -97,7 +116,7 @@ export default {
|
||||
},
|
||||
actions: {
|
||||
sendMessage: 'Send Message',
|
||||
mention: '@ Mention',
|
||||
mention: 'Mention',
|
||||
rename: 'Rename'
|
||||
},
|
||||
manage: {
|
||||
@@ -151,7 +170,8 @@ export default {
|
||||
title: 'Invite as Assistant',
|
||||
subtitle: 'Select an AI model to join the workspace',
|
||||
instances: 'Number of Instances',
|
||||
unlimitedAccess: 'Unlimited Access',
|
||||
instanceLimit: 'Max {count} instances',
|
||||
unlimitedAccess: 'Unlimited Mode',
|
||||
unlimitedAccessDesc: 'Bypass usage limits',
|
||||
sandboxed: 'Sandboxed environment',
|
||||
send: 'Send Invitation',
|
||||
@@ -191,7 +211,7 @@ export default {
|
||||
subtitle: 'Configure active skills for {channel}',
|
||||
tabs: {
|
||||
current: 'Current Skills',
|
||||
library: 'My Skills Library'
|
||||
library: 'My Skills'
|
||||
}
|
||||
},
|
||||
current: {
|
||||
@@ -288,6 +308,8 @@ export default {
|
||||
searchPlaceholder: 'Search plugins, integrations, and themes...',
|
||||
browseStore: 'Browse Store',
|
||||
myPlugins: 'My Plugins',
|
||||
importTitle: 'Import Plugin',
|
||||
importSubtitle: 'From URL or Local File',
|
||||
categories: {
|
||||
all: 'All Plugins',
|
||||
productivity: 'Productivity',
|
||||
@@ -338,7 +360,7 @@ export default {
|
||||
searchPlaceholder: 'Search skill folders, templates, and toolkits...',
|
||||
tabs: {
|
||||
store: 'Store',
|
||||
installed: 'Installed'
|
||||
installed: 'My Skills'
|
||||
},
|
||||
filters: {
|
||||
all: 'All Skills',
|
||||
@@ -351,7 +373,7 @@ export default {
|
||||
syncPlaceholder: 'Paste sync URL...',
|
||||
syncNow: 'Sync now',
|
||||
installFolder: 'Install Folder',
|
||||
installed: 'Installed',
|
||||
installed: 'My Skills',
|
||||
skills: {
|
||||
automation: {
|
||||
title: 'Automation Skill',
|
||||
@@ -380,67 +402,92 @@ export default {
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
createTitle: 'Create New Team',
|
||||
createSubtitle: 'Initialize a fresh collaborative environment',
|
||||
openTitle: 'Open Folder',
|
||||
openSubtitle: 'Pick a folder to start or resume a workspace',
|
||||
recentTitle: 'Recent Workspaces',
|
||||
more: 'More',
|
||||
searchPlaceholder: 'Search teams...',
|
||||
menu: {
|
||||
settings: 'Workspace Settings',
|
||||
invite: 'Invite Members',
|
||||
copyId: 'Copy ID',
|
||||
leave: 'Leave Workspace'
|
||||
},
|
||||
activeLabel: 'ACTIVE',
|
||||
times: {
|
||||
twoMinutes: '2m ago',
|
||||
fourHours: '4h ago',
|
||||
oneDay: '1d ago'
|
||||
},
|
||||
list: {
|
||||
frontend: {
|
||||
title: 'Frontend Squad',
|
||||
desc: 'Main monorepo for the consumer-facing React application.'
|
||||
},
|
||||
mobile: {
|
||||
title: 'Mobile API',
|
||||
desc: 'GraphQL gateway and microservices for iOS/Android apps.'
|
||||
},
|
||||
infrastructure: {
|
||||
title: 'Infrastructure',
|
||||
desc: 'Terraform scripts and Kubernetes configuration files.'
|
||||
}
|
||||
},
|
||||
recent: {
|
||||
designSystems: 'Design Systems',
|
||||
marketingTeam: 'Marketing Team',
|
||||
backendCore: 'Backend Core'
|
||||
}
|
||||
searchPlaceholder: 'Search folders...',
|
||||
emptyTitle: 'No recent workspaces',
|
||||
emptySubtitle: 'Open a folder to start your first workspace.',
|
||||
openAction: 'Open',
|
||||
noResults: 'No matching workspaces',
|
||||
openErrorTitle: 'Unable to open workspace',
|
||||
readOnlyTitle: 'Workspace is read-only',
|
||||
readOnlySubtitle: 'This folder is not writable. Project metadata will not be saved.',
|
||||
registryMismatchTitle: 'Workspace location changed',
|
||||
registryMismatchMessage: 'The workspace location has changed.\nPrevious path: {oldPath}\nCurrent path: {newPath}\nDid you move the original project or copy a new duplicate?',
|
||||
registryMismatchMoved: 'Moved',
|
||||
registryMismatchCopied: 'Copied'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
preferences: 'Preferences',
|
||||
preferencesSubtitle: 'Customize your account, notifications, and workspace preferences.',
|
||||
accountSubtitle: 'Manage your profile, presence, and contact details.',
|
||||
avatarTitle: 'Avatar',
|
||||
avatarSubtitle: 'Pick a style or upload your own.',
|
||||
avatarUpload: 'Upload Image',
|
||||
avatarReset: 'Use Style',
|
||||
avatarHint: 'PNG/JPG/WEBP up to 2MB.',
|
||||
avatarUploads: 'Uploads',
|
||||
avatarErrors: {
|
||||
invalidType: 'Please upload an image file.',
|
||||
tooLarge: 'Image is larger than 2MB.',
|
||||
storageFailed: 'Unable to save avatar.'
|
||||
},
|
||||
avatarOptions: {
|
||||
orbit: 'Orbit',
|
||||
ember: 'Ember',
|
||||
mint: 'Mint',
|
||||
canyon: 'Canyon',
|
||||
storm: 'Storm'
|
||||
},
|
||||
displayName: 'Display Name',
|
||||
displayNamePlaceholder: 'Enter your display name',
|
||||
emailAddress: 'Email Address',
|
||||
emailPlaceholder: 'name@example.com',
|
||||
emailPlaceholder: 'name{at}example.com',
|
||||
changeEmail: 'Change Email',
|
||||
jobTitle: 'Job Title',
|
||||
jobTitlePlaceholder: 'e.g. Product Designer',
|
||||
timeZone: 'Time Zone',
|
||||
timeZones: {
|
||||
utc: 'UTC',
|
||||
pacificMidway: 'Pacific/Midway',
|
||||
pacificHonolulu: 'Pacific/Honolulu',
|
||||
americaAnchorage: 'America/Anchorage',
|
||||
americaLosAngeles: 'America/Los_Angeles',
|
||||
americaDenver: 'America/Denver',
|
||||
americaChicago: 'America/Chicago',
|
||||
americaNewYork: 'America/New_York',
|
||||
americaHalifax: 'America/Halifax',
|
||||
americaSaoPaulo: 'America/Sao_Paulo',
|
||||
atlanticAzores: 'Atlantic/Azores',
|
||||
europeLondon: 'Europe/London',
|
||||
europeParis: 'Europe/Paris',
|
||||
europeHelsinki: 'Europe/Helsinki',
|
||||
europeMoscow: 'Europe/Moscow',
|
||||
asiaDubai: 'Asia/Dubai',
|
||||
asiaKarachi: 'Asia/Karachi',
|
||||
asiaDhaka: 'Asia/Dhaka',
|
||||
asiaBangkok: 'Asia/Bangkok',
|
||||
asiaShanghai: 'Asia/Shanghai',
|
||||
asiaTokyo: 'Asia/Tokyo',
|
||||
australiaSydney: 'Australia/Sydney',
|
||||
pacificNoumea: 'Pacific/Noumea',
|
||||
pacificAuckland: 'Pacific/Auckland'
|
||||
},
|
||||
status: 'Status',
|
||||
statusMessage: 'Status Message',
|
||||
statusMessagePlaceholder: 'Share what you are working on',
|
||||
statusOptions: {
|
||||
online: 'Online',
|
||||
away: 'Away',
|
||||
dnd: 'Do Not Disturb'
|
||||
working: 'Working',
|
||||
dnd: 'Do Not Disturb',
|
||||
offline: 'Offline'
|
||||
},
|
||||
language: 'Language',
|
||||
languageDefault: 'Default',
|
||||
changesApply: 'Changes apply after restart.',
|
||||
spellCheck: 'Spell Check',
|
||||
defaultMember: 'Default Member',
|
||||
selectMember: 'Select Member',
|
||||
refreshList: 'Refresh List',
|
||||
@@ -454,12 +501,13 @@ export default {
|
||||
members: 'Members',
|
||||
notifications: 'Notifications',
|
||||
keybinds: 'Keybinds',
|
||||
createTeam: 'Create Team',
|
||||
leaveTeam: 'Leave Team',
|
||||
logOut: 'Log Out',
|
||||
memberName: 'Member Name',
|
||||
memberNamePlaceholder: 'Enter member label',
|
||||
commandInput: 'Command Line Input',
|
||||
commandPlaceholder: 'e.g. /usr/local/bin/my-cli',
|
||||
commandPlaceholder: 'CLI startup command, e.g. gemini',
|
||||
confirm: 'Confirm',
|
||||
notificationsSubtitle: 'Choose when and how you want to be notified.',
|
||||
notificationOptions: {
|
||||
|
||||
+96
-48
@@ -1,21 +1,40 @@
|
||||
export default {
|
||||
app: {
|
||||
name: 'Nexus Dashboard'
|
||||
name: 'golutra',
|
||||
windowControls: {
|
||||
minimize: '最小化',
|
||||
maximize: '最大化',
|
||||
close: '关闭'
|
||||
}
|
||||
},
|
||||
nav: {
|
||||
chat: '聊天与弹窗',
|
||||
terminal: '终端',
|
||||
workspaces: '工作区',
|
||||
store: '技能商店',
|
||||
plugins: '插件',
|
||||
settings: '设置'
|
||||
},
|
||||
terminal: {
|
||||
title: '终端',
|
||||
subtitle: '在一个工作区内运行多个终端会话。',
|
||||
newTab: '新建标签',
|
||||
emptyTabs: '暂无终端。',
|
||||
emptyTitle: '没有活动终端',
|
||||
emptySubtitle: '新建一个终端标签开始。',
|
||||
unavailableTitle: '终端不可用',
|
||||
unavailableSubtitle: '请在 Tauri 桌面应用中打开该页面。',
|
||||
errorTitle: '终端启动失败',
|
||||
errorSubtitle: '请检查桌面运行时后重试。',
|
||||
resourceLimit: '系统资源不足,无法启动新终端,请先关闭部分后台任务。'
|
||||
},
|
||||
common: {
|
||||
userAvatarAlt: '用户',
|
||||
remove: '删除'
|
||||
},
|
||||
chat: {
|
||||
channelName: 'design-critique',
|
||||
channelDisplay: '#design-critique',
|
||||
channelName: 'general',
|
||||
channelDisplay: '#general',
|
||||
channelDescription: '',
|
||||
directMessageDescription: '与 {name} 的私信',
|
||||
header: {
|
||||
@@ -35,7 +54,7 @@ export default {
|
||||
}
|
||||
},
|
||||
sidebar: {
|
||||
workspaceName: '前端小队',
|
||||
workspaceName: '工作区',
|
||||
channels: '频道',
|
||||
directMessages: '私信',
|
||||
channelList: {
|
||||
@@ -97,7 +116,7 @@ export default {
|
||||
},
|
||||
actions: {
|
||||
sendMessage: '发送消息',
|
||||
mention: '@',
|
||||
mention: '提及',
|
||||
rename: '更改名称'
|
||||
},
|
||||
manage: {
|
||||
@@ -151,7 +170,8 @@ export default {
|
||||
title: '以助手身份邀请',
|
||||
subtitle: '选择要加入工作区的 AI 模型',
|
||||
instances: '实例数量',
|
||||
unlimitedAccess: '无限访问',
|
||||
instanceLimit: '最多 {count} 个实例',
|
||||
unlimitedAccess: '无限制模式',
|
||||
unlimitedAccessDesc: '绕过使用限制',
|
||||
sandboxed: '沙盒环境',
|
||||
send: '发送邀请',
|
||||
@@ -191,7 +211,7 @@ export default {
|
||||
subtitle: '为 {channel} 配置已启用技能',
|
||||
tabs: {
|
||||
current: '当前技能',
|
||||
library: '我的技能库'
|
||||
library: '我的技能'
|
||||
}
|
||||
},
|
||||
current: {
|
||||
@@ -288,6 +308,8 @@ export default {
|
||||
searchPlaceholder: '搜索插件、集成和主题...',
|
||||
browseStore: '浏览商店',
|
||||
myPlugins: '我的插件',
|
||||
importTitle: '导入插件',
|
||||
importSubtitle: '从 URL 或本地文件',
|
||||
categories: {
|
||||
all: '全部插件',
|
||||
productivity: '效率',
|
||||
@@ -338,7 +360,7 @@ export default {
|
||||
searchPlaceholder: '搜索技能文件夹、模板和工具包...',
|
||||
tabs: {
|
||||
store: '商店',
|
||||
installed: '已安装'
|
||||
installed: '我的技能'
|
||||
},
|
||||
filters: {
|
||||
all: '全部技能',
|
||||
@@ -351,7 +373,7 @@ export default {
|
||||
syncPlaceholder: '粘贴同步 URL...',
|
||||
syncNow: '立即同步',
|
||||
installFolder: '安装文件夹',
|
||||
installed: '已安装',
|
||||
installed: '我的技能',
|
||||
skills: {
|
||||
automation: {
|
||||
title: '自动化技能',
|
||||
@@ -380,67 +402,92 @@ export default {
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
createTitle: '创建新团队',
|
||||
createSubtitle: '初始化全新的协作环境',
|
||||
openTitle: '打开文件夹',
|
||||
openSubtitle: '选择一个文件夹开始或恢复工作区',
|
||||
recentTitle: '最近的工作区',
|
||||
more: '更多',
|
||||
searchPlaceholder: '搜索团队...',
|
||||
menu: {
|
||||
settings: '工作区设置',
|
||||
invite: '邀请成员',
|
||||
copyId: '复制 ID',
|
||||
leave: '退出工作区'
|
||||
},
|
||||
activeLabel: '活跃',
|
||||
times: {
|
||||
twoMinutes: '2 分钟前',
|
||||
fourHours: '4 小时前',
|
||||
oneDay: '1 天前'
|
||||
},
|
||||
list: {
|
||||
frontend: {
|
||||
title: '前端小队',
|
||||
desc: '面向用户的 React 应用主仓库。'
|
||||
},
|
||||
mobile: {
|
||||
title: '移动端 API',
|
||||
desc: '面向 iOS/Android 的 GraphQL 网关与微服务。'
|
||||
},
|
||||
infrastructure: {
|
||||
title: '基础设施',
|
||||
desc: 'Terraform 脚本与 Kubernetes 配置文件。'
|
||||
}
|
||||
},
|
||||
recent: {
|
||||
designSystems: '设计系统',
|
||||
marketingTeam: '市场团队',
|
||||
backendCore: '后端核心'
|
||||
}
|
||||
searchPlaceholder: '搜索文件夹...',
|
||||
emptyTitle: '暂无最近工作区',
|
||||
emptySubtitle: '打开文件夹以创建你的第一个工作区。',
|
||||
openAction: '打开',
|
||||
noResults: '未找到匹配的工作区',
|
||||
openErrorTitle: '无法打开工作区',
|
||||
readOnlyTitle: '工作区只读',
|
||||
readOnlySubtitle: '当前文件夹不可写,项目数据不会保存。',
|
||||
registryMismatchTitle: '工作区位置变化',
|
||||
registryMismatchMessage: '检测到项目位置发生变化。\n旧路径: {oldPath}\n当前路径: {newPath}\n你是移动了原项目,还是复制了一个新副本?',
|
||||
registryMismatchMoved: '移动了',
|
||||
registryMismatchCopied: '复制副本'
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
preferences: '偏好设置',
|
||||
preferencesSubtitle: '自定义账号、通知与工作区偏好。',
|
||||
accountSubtitle: '管理你的资料、状态与联系方式。',
|
||||
avatarTitle: '头像',
|
||||
avatarSubtitle: '选择样式或上传图片。',
|
||||
avatarUpload: '上传图片',
|
||||
avatarReset: '恢复样式',
|
||||
avatarHint: '支持 PNG/JPG/WEBP,最大 2MB。',
|
||||
avatarUploads: '已上传',
|
||||
avatarErrors: {
|
||||
invalidType: '请上传图片文件。',
|
||||
tooLarge: '图片超过 2MB。',
|
||||
storageFailed: '头像存储失败。'
|
||||
},
|
||||
avatarOptions: {
|
||||
orbit: '轨道',
|
||||
ember: '余烬',
|
||||
mint: '薄荷',
|
||||
canyon: '峡谷',
|
||||
storm: '风暴'
|
||||
},
|
||||
displayName: '显示名称',
|
||||
displayNamePlaceholder: '输入显示名称',
|
||||
emailAddress: '邮箱地址',
|
||||
emailPlaceholder: 'name@example.com',
|
||||
emailPlaceholder: 'name{at}example.com',
|
||||
changeEmail: '更改邮箱',
|
||||
jobTitle: '职位',
|
||||
jobTitlePlaceholder: '例如 产品设计师',
|
||||
timeZone: '时区',
|
||||
timeZones: {
|
||||
utc: 'UTC协调世界时',
|
||||
pacificMidway: '太平洋/中途岛',
|
||||
pacificHonolulu: '太平洋/檀香山',
|
||||
americaAnchorage: '美洲/安克雷奇',
|
||||
americaLosAngeles: '美洲/洛杉矶',
|
||||
americaDenver: '美洲/丹佛',
|
||||
americaChicago: '美洲/芝加哥',
|
||||
americaNewYork: '美洲/纽约',
|
||||
americaHalifax: '美洲/哈利法克斯',
|
||||
americaSaoPaulo: '美洲/圣保罗',
|
||||
atlanticAzores: '大西洋/亚速尔',
|
||||
europeLondon: '欧洲/伦敦',
|
||||
europeParis: '欧洲/巴黎',
|
||||
europeHelsinki: '欧洲/赫尔辛基',
|
||||
europeMoscow: '欧洲/莫斯科',
|
||||
asiaDubai: '亚洲/迪拜',
|
||||
asiaKarachi: '亚洲/卡拉奇',
|
||||
asiaDhaka: '亚洲/达卡',
|
||||
asiaBangkok: '亚洲/曼谷',
|
||||
asiaShanghai: '亚洲/上海',
|
||||
asiaTokyo: '亚洲/东京',
|
||||
australiaSydney: '澳大利亚/悉尼',
|
||||
pacificNoumea: '太平洋/努美阿',
|
||||
pacificAuckland: '太平洋/奥克兰'
|
||||
},
|
||||
status: '状态',
|
||||
statusMessage: '状态信息',
|
||||
statusMessagePlaceholder: '分享你正在做的事情',
|
||||
statusOptions: {
|
||||
online: '在线',
|
||||
away: '离开',
|
||||
dnd: '请勿打扰'
|
||||
working: '工作中',
|
||||
dnd: '请勿打扰',
|
||||
offline: '离线'
|
||||
},
|
||||
language: '语言',
|
||||
languageDefault: '默认',
|
||||
changesApply: '重启后生效。',
|
||||
spellCheck: '拼写检查',
|
||||
defaultMember: '默认成员',
|
||||
selectMember: '选择成员',
|
||||
refreshList: '刷新列表',
|
||||
@@ -454,12 +501,13 @@ export default {
|
||||
members: '成员',
|
||||
notifications: '通知',
|
||||
keybinds: '快捷键',
|
||||
createTeam: '新建团队',
|
||||
leaveTeam: '退出团队',
|
||||
logOut: '退出账号',
|
||||
memberName: '成员名称',
|
||||
memberNamePlaceholder: '输入成员标签',
|
||||
commandInput: '命令行输入',
|
||||
commandPlaceholder: '例如 /usr/local/bin/my-cli',
|
||||
commandPlaceholder: '终端启动命令 例如:gemini',
|
||||
confirm: '确认',
|
||||
notificationsSubtitle: '选择你接收通知的方式与时机。',
|
||||
notificationOptions: {
|
||||
|
||||
+361
-4
@@ -1,3 +1,265 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Be Vietnam Pro";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BeVietnamPro-300.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Be Vietnam Pro";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BeVietnamPro-400.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Be Vietnam Pro";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BeVietnamPro-500.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Be Vietnam Pro";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BeVietnamPro-600.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Be Vietnam Pro";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BeVietnamPro-700.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Material Symbols Outlined";
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/MaterialSymbolsOutlined.woff2") format("woff2");
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: "Material Symbols Outlined";
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: "liga";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
max-width: 1px !important;
|
||||
max-height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
margin: 0 !important;
|
||||
opacity: 0 !important;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
border: 1px solid transparent;
|
||||
background-color: rgb(var(--color-background));
|
||||
background-image: var(--app-gradient), var(--app-gradient-spot);
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
box-shadow: var(--window-glow);
|
||||
}
|
||||
|
||||
.window-frame--max {
|
||||
border-radius: 0;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.window-frame--inactive {
|
||||
box-shadow: var(--window-glow-inactive);
|
||||
}
|
||||
|
||||
.window-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 10px 0 14px;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.titlebar--mac {
|
||||
padding-left: 82px;
|
||||
}
|
||||
|
||||
.titlebar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titlebar__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text) / 0.7);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.titlebar__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.titlebar__btn {
|
||||
width: 46px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgb(var(--color-text) / 0.6);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.titlebar__btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.titlebar__btn:hover {
|
||||
background: rgb(var(--color-overlay) / 0.12);
|
||||
color: rgb(var(--color-text) / 0.9);
|
||||
}
|
||||
|
||||
.titlebar__btn:active {
|
||||
background: rgb(var(--color-overlay) / 0.2);
|
||||
}
|
||||
|
||||
.titlebar__btn--close:hover {
|
||||
background: rgb(239 68 68 / 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.window-frame--inactive .titlebar {
|
||||
}
|
||||
|
||||
.window-frame--inactive .titlebar__title {
|
||||
color: rgb(var(--color-text) / 0.45);
|
||||
}
|
||||
|
||||
.window-frame--inactive .titlebar__btn {
|
||||
color: rgb(var(--color-text) / 0.45);
|
||||
}
|
||||
|
||||
.selectable,
|
||||
.allow-select,
|
||||
.prose {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable='true'] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -47,6 +309,8 @@
|
||||
--shadow-modal: 0 25px 50px -12px rgb(0 0 0 / 0.5);
|
||||
--app-gradient: radial-gradient(1200px circle at top right, rgb(var(--color-primary) / 0.18), rgb(var(--color-background)) 55%, rgb(5 5 5) 100%);
|
||||
--app-gradient-spot: radial-gradient(900px circle at 20% 20%, rgb(var(--color-primary) / 0.12), transparent 60%);
|
||||
--window-glow: 0 0 0 1px rgb(var(--color-primary) / 0.28), 0 0 18px rgb(var(--color-primary) / 0.18);
|
||||
--window-glow-inactive: 0 0 0 1px rgb(var(--color-border) / 0.08);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -68,6 +332,8 @@
|
||||
--shadow-modal: 0 25px 50px -12px rgb(15 23 42 / 0.16);
|
||||
--app-gradient: radial-gradient(1200px circle at top right, rgb(var(--color-primary) / 0.12), rgb(var(--color-background)) 45%, rgb(var(--color-panel)) 100%);
|
||||
--app-gradient-spot: radial-gradient(800px circle at 20% 15%, rgb(194 231 255 / 0.45), transparent 60%);
|
||||
--window-glow: 0 0 0 1px rgb(var(--color-primary) / 0.22), 0 0 14px rgb(var(--color-primary) / 0.12);
|
||||
--window-glow-inactive: 0 0 0 1px rgb(var(--color-border) / 0.18);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@@ -99,10 +365,7 @@
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
background-color: rgb(var(--color-background));
|
||||
background-image: var(--app-gradient), var(--app-gradient-spot);
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .message-bubble--me {
|
||||
@@ -171,6 +434,10 @@
|
||||
background-color: rgb(var(--color-overlay) / 0.2) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .bg-white\/15 {
|
||||
background-color: rgb(var(--color-overlay) / 0.15) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .bg-white\/10 {
|
||||
background-color: rgb(var(--color-overlay) / 0.1) !important;
|
||||
}
|
||||
@@ -195,6 +462,14 @@
|
||||
background-color: rgb(var(--color-overlay) / 0.05) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .bg-white\/\[0\.12\] {
|
||||
background-color: rgb(var(--color-overlay) / 0.12) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .bg-white\/\[0\.16\] {
|
||||
background-color: rgb(var(--color-overlay) / 0.16) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .bg-white\/\[0\.06\] {
|
||||
background-color: rgb(var(--color-overlay) / 0.06) !important;
|
||||
}
|
||||
@@ -203,6 +478,14 @@
|
||||
background-color: rgb(var(--color-overlay) / 0.07) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .hover\:bg-white\/15:hover {
|
||||
background-color: rgb(var(--color-overlay) / 0.15) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .hover\:bg-white\/\[0\.16\]:hover {
|
||||
background-color: rgb(var(--color-overlay) / 0.16) !important;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .border-white\/20 {
|
||||
border-color: rgb(var(--color-border) / 0.45) !important;
|
||||
}
|
||||
@@ -226,3 +509,77 @@
|
||||
:root[data-resolved-theme='light'] .ring-white\/5 {
|
||||
--tw-ring-color: rgb(var(--color-border) / 0.2) !important;
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
appearance: base-select;
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgb(var(--color-border) / 0.1);
|
||||
background: linear-gradient(135deg, rgb(var(--color-background)) 0%, rgb(var(--color-panel)) 100%);
|
||||
color: rgb(var(--color-text));
|
||||
color-scheme: dark;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
padding: 0.625rem 2.5rem 0.625rem 1rem;
|
||||
}
|
||||
|
||||
.settings-select:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary) / 0.5);
|
||||
box-shadow: 0 0 0 1px rgb(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.settings-select:open {
|
||||
border-color: rgb(var(--color-primary) / 0.4);
|
||||
box-shadow: 0 0 0 1px rgb(var(--color-primary) / 0.35);
|
||||
}
|
||||
|
||||
.settings-select::picker(select) {
|
||||
background: linear-gradient(135deg, rgb(var(--color-background)) 0%, rgb(var(--color-panel-strong)) 100%);
|
||||
border: 1px solid rgb(var(--color-border) / 0.1);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
box-shadow: var(--shadow-modal);
|
||||
color: rgb(var(--color-text));
|
||||
}
|
||||
|
||||
.settings-select option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: rgb(var(--color-text));
|
||||
background: rgb(var(--color-background));
|
||||
}
|
||||
|
||||
.settings-select option:hover,
|
||||
.settings-select option:focus-visible {
|
||||
background: rgb(var(--color-text-muted) / 0.55);
|
||||
color: rgb(var(--color-background));
|
||||
}
|
||||
|
||||
.settings-select option:checked {
|
||||
background: rgb(255 255 255 / 0.35);
|
||||
color: rgb(var(--color-background));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-select option::checkmark {
|
||||
color: rgb(var(--color-background));
|
||||
}
|
||||
|
||||
.settings-select::picker-icon {
|
||||
color: rgb(var(--color-text) / 0.6);
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .settings-select {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .settings-select option:hover,
|
||||
:root[data-resolved-theme='light'] .settings-select option:focus-visible,
|
||||
:root[data-resolved-theme='light'] .settings-select option:checked {
|
||||
background: rgb(var(--color-panel-strong));
|
||||
color: rgb(var(--color-text));
|
||||
}
|
||||
|
||||
:root[data-resolved-theme='light'] .settings-select option::checkmark {
|
||||
color: rgb(var(--color-text));
|
||||
}
|
||||
Reference in new issue
Block a user