添加切换工作目录功能

This commit is contained in:
2026-07-08 16:44:58 +08:00
parent aabe685c48
commit 52abffc3c4
4 changed files with 1172 additions and 54 deletions

View File

@@ -36,5 +36,5 @@
"topic_20260707-092315_b8c3939bfc696802": "当我点击目录树切换显示文件的时候,右…",
"topic_20260707-092711_af706c3fcc7e435e": "帮我检查一下app.vue里的too…",
"topic_20260707-095503_bf879b65e4666516": "帮我移除markdownEditor…",
"topic_20260708-020616_9ab54d98daf65053": "新的会话"
"topic_20260708-020616_9ab54d98daf65053": "帮我分析一下现在的markdownE…"
}

View File

@@ -11,6 +11,29 @@ struct DirEntry {
is_dir: bool,
}
fn is_hidden_entry(entry: &fs::DirEntry) -> bool {
let name = entry.file_name();
let is_dot_hidden = name.to_string_lossy().starts_with('.');
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
let is_windows_hidden = entry
.metadata()
.map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0)
.unwrap_or(false);
return is_dot_hidden || is_windows_hidden;
}
#[cfg(not(windows))]
{
is_dot_hidden
}
}
#[tauri::command]
fn pick_folder(app: tauri::AppHandle) -> Option<String> {
app.dialog()
@@ -22,13 +45,19 @@ fn pick_folder(app: tauri::AppHandle) -> Option<String> {
#[tauri::command]
fn read_dir(path: String) -> Result<Vec<DirEntry>, String> {
let entries = fs::read_dir(&path).map_err(|e| format!("无法读取目录: {}", e))?;
let allowed_extensions = ["md", "txt", "png", "jpg", "jpeg", "gif", "svg", "webp", "bmp"];
let allowed_extensions = [
"md", "png", "jpg", "jpeg", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "ogv",
];
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?;
if is_hidden_entry(&entry) {
continue;
}
let path = entry.path();
let is_dir = path.is_dir();
// Always include directories; filter files by extension
// Keep the workspace tree focused on markdown documents and embeddable media.
if !is_dir {
let ext = path
.extension()
@@ -39,10 +68,7 @@ fn read_dir(path: String) -> Result<Vec<DirEntry>, String> {
continue;
}
}
let name = entry
.file_name()
.to_string_lossy()
.to_string();
let name = entry.file_name().to_string_lossy().to_string();
result.push(DirEntry {
name,
path: path.to_string_lossy().to_string(),
@@ -82,7 +108,14 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![greet, pick_folder, read_dir, read_file, write_file, pick_save_file])
.invoke_handler(tauri::generate_handler![
greet,
pick_folder,
read_dir,
read_file,
write_file,
pick_save_file
])
.setup(|app| {
// ---- File menu ----
let new = MenuItemBuilder::with_id("new", "新建")
@@ -151,12 +184,9 @@ pub fn run() {
.build()?;
// ---- Help menu ----
let about = MenuItemBuilder::with_id("about", "关于")
.build(app)?;
let about = MenuItemBuilder::with_id("about", "关于").build(app)?;
let help_menu = SubmenuBuilder::new(app, "帮助")
.item(&about)
.build()?;
let help_menu = SubmenuBuilder::new(app, "帮助").item(&about).build()?;
// ---- Build the menu bar ----
let menu = MenuBuilder::new(app)
@@ -180,11 +210,7 @@ pub fn run() {
let handle = app_handle.clone();
// Spawn because dialog is blocking
std::thread::spawn(move || {
if let Some(folder) = handle
.dialog()
.file()
.blocking_pick_folder()
{
if let Some(folder) = handle.dialog().file().blocking_pick_folder() {
let path = folder.to_string();
let _ = handle.emit("folder-opened", path);
}

View File

@@ -73,9 +73,17 @@ interface DirEntry {
children?: DirEntry[];
}
interface WorkspaceEntry {
name: string;
path: string;
}
const folderPath = ref("");
const dirEntries = ref<DirEntry[]>([]);
const LAST_WORKSPACE_PATH_KEY = "yurou:last-workspace-path";
const RECENT_WORKSPACES_KEY = "yurou:recent-workspaces";
const MAX_RECENT_WORKSPACES = 12;
const recentWorkspaces = ref<WorkspaceEntry[]>(loadRecentWorkspaces());
const editorResetKey = ref(0);
// ---- editor zoom (Ctrl+scroll) ----
@@ -384,34 +392,178 @@ function clearLastWorkspacePath() {
}
}
async function loadDir(path: string): Promise<boolean> {
folderPath.value = path;
dirEntries.value = [];
function normalizeWorkspacePath(path: string) {
const trimmed = path.trim();
if (!trimmed) return "";
const normalized = trimmed.replace(/[\\/]+$/, "");
if (!normalized) return trimmed[0] || "";
if (/^[A-Za-z]:$/.test(normalized) && /^[A-Za-z]:[\\/]+$/.test(trimmed)) {
return `${normalized}\\`;
}
return normalized;
}
function getWorkspaceIdentity(path: string) {
return normalizeWorkspacePath(path).toLocaleLowerCase();
}
function getWorkspaceName(path: string): string {
const normalized = normalizeWorkspacePath(path);
return normalized.split(/[/\\]/).pop() || normalized || path;
}
function createWorkspaceEntry(path: string): WorkspaceEntry {
const normalized = normalizeWorkspacePath(path);
return {
name: getWorkspaceName(normalized),
path: normalized,
};
}
function getStoredWorkspacePath(item: unknown) {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
const maybeWorkspace = item as { path?: unknown };
if (typeof maybeWorkspace.path === "string") return maybeWorkspace.path;
}
return null;
}
function dedupeWorkspaces(paths: string[]) {
const seen = new Set<string>();
const workspaces: WorkspaceEntry[] = [];
for (const path of paths) {
const normalized = normalizeWorkspacePath(path);
if (!normalized) continue;
const identity = getWorkspaceIdentity(normalized);
if (seen.has(identity)) continue;
seen.add(identity);
workspaces.push(createWorkspaceEntry(normalized));
}
return workspaces.slice(0, MAX_RECENT_WORKSPACES);
}
function loadRecentWorkspaces(): WorkspaceEntry[] {
try {
dirEntries.value = await invoke<DirEntry[]>("read_dir", {path});
const stored = localStorage.getItem(RECENT_WORKSPACES_KEY);
if (!stored) return [];
const parsed = JSON.parse(stored) as unknown;
if (!Array.isArray(parsed)) return [];
return dedupeWorkspaces(
parsed
.map(getStoredWorkspacePath)
.filter((path): path is string => Boolean(path)),
);
} catch {
return [];
}
}
function saveRecentWorkspaces(workspaces: WorkspaceEntry[] = recentWorkspaces.value) {
try {
localStorage.setItem(RECENT_WORKSPACES_KEY, JSON.stringify(workspaces));
} catch { /* noop */
}
}
function rememberWorkspace(path: string) {
const normalized = normalizeWorkspacePath(path);
if (!normalized) return;
recentWorkspaces.value = dedupeWorkspaces([
normalized,
...recentWorkspaces.value.map((workspace) => workspace.path),
]);
saveRecentWorkspaces();
}
function forgetWorkspace(path: string) {
const identity = getWorkspaceIdentity(path);
recentWorkspaces.value = recentWorkspaces.value.filter((workspace) => {
return getWorkspaceIdentity(workspace.path) !== identity;
});
saveRecentWorkspaces();
}
async function removeRecentWorkspace(path: string) {
const isCurrent = Boolean(folderPath.value) && getWorkspaceIdentity(folderPath.value) === getWorkspaceIdentity(path);
if (isCurrent) {
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved) return;
resetWorkspaceAndEditorState();
clearLastWorkspacePath();
}
forgetWorkspace(path);
}
async function loadDir(path: string): Promise<boolean> {
const normalized = normalizeWorkspacePath(path);
try {
const entries = await invoke<DirEntry[]>("read_dir", {path: normalized});
folderPath.value = normalized;
dirEntries.value = entries;
return true;
} catch (e) {
console.error("Failed to read directory:", e);
folderPath.value = "";
dirEntries.value = [];
return false;
}
}
async function switchWorkspace(path: string, options: { persist?: boolean; resetEditor?: boolean } = {}) {
if (options.resetEditor !== false) {
async function saveCurrentWorkspaceDocumentBeforeSwitch() {
if (!currentFilePath.value && !hasDraftDocument.value) return true;
const contentToSave = getLatestEditorContent();
if (!currentFilePath.value && !contentToSave.trim()) return true;
return doSave(contentToSave);
}
function resetWorkspaceAndEditorState() {
folderPath.value = "";
dirEntries.value = [];
resetMarkdownEditorState();
}
const loaded = await loadDir(path);
async function switchWorkspace(path: string, options: { persist?: boolean; resetEditor?: boolean } = {}) {
const normalized = normalizeWorkspacePath(path);
if (loaded && options.persist !== false) {
saveLastWorkspacePath(path);
} else if (!loaded) {
if (options.resetEditor !== false) {
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved) return false;
resetWorkspaceAndEditorState();
}
const loaded = await loadDir(normalized);
if (!loaded) {
forgetWorkspace(normalized);
const lastWorkspacePath = getLastWorkspacePath();
if (lastWorkspacePath && getWorkspaceIdentity(lastWorkspacePath) === getWorkspaceIdentity(normalized)) {
clearLastWorkspacePath();
}
return loaded;
return false;
}
rememberWorkspace(normalized);
if (options.persist !== false) {
saveLastWorkspacePath(normalized);
}
return true;
}
async function restoreLastWorkspace() {
@@ -486,11 +638,11 @@ async function doSave(contentOverride?: string) {
// No file path yet — prompt user to pick a save location
try {
const chosen = await invoke<string | null>("pick_save_file");
if (!chosen) return; // user cancelled
if (!chosen) return false; // user cancelled
currentFilePath.value = chosen;
} catch (e) {
console.error("Save dialog failed:", e);
return;
return false;
}
}
@@ -500,9 +652,12 @@ async function doSave(contentOverride?: string) {
setTimeout(() => {
saveStatus.value = "";
}, 3000);
hasDraftDocument.value = false;
return true;
} catch (e) {
console.error("Save failed:", e);
saveStatus.value = `保存失败: ${e}`;
return false;
}
}
@@ -580,7 +735,10 @@ registerTauriListener("menu-event", (event) => {
:settings-initial-page="settingsInitialPage"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
:recent-workspaces="recentWorkspaces"
@open-folder="onOpenFolder"
@switch-workspace="(path: string) => switchWorkspace(path)"
@remove-workspace="removeRecentWorkspace"
@toggle-folder="onToggleFolder"
@open-file="onOpenFile"
@update-default-document-mode="updateDefaultDocumentMode"
@@ -760,27 +918,27 @@ registerTauriListener("menu-event", (event) => {
</Transition>
</main>
</div>
<footer class="app-footer backdrop-blur-sm">
<div class="flex items-center gap-4 px-4 py-1.5">
<!-- <footer class="app-footer backdrop-blur-sm">-->
<!-- <div class="flex items-center gap-4 px-4 py-1.5">-->
<!-- Current file / save status -->
<span
v-if="saveStatus"
class="text-xs"
:class="saveStatus.startsWith('保存失败') ? 'text-red-500' : 'text-emerald-600'"
>{{ saveStatus }}</span>
<span v-else-if="currentFilePath" class="app-muted-text text-xs">
{{ currentFilePath.split(/[/\\]/).pop() }}
</span>
<span v-else class="app-subtle-text text-xs">未保存</span>
<!-- &lt;!&ndash; Current file / save status &ndash;&gt;-->
<!-- <span-->
<!-- v-if="saveStatus"-->
<!-- class="text-xs"-->
<!-- :class="saveStatus.startsWith('保存失败') ? 'text-red-500' : 'text-emerald-600'"-->
<!-- >{{ saveStatus }}</span>-->
<!-- <span v-else-if="currentFilePath" class="app-muted-text text-xs">-->
<!-- {{ currentFilePath.split(/[/\\]/).pop() }}-->
<!-- </span>-->
<!-- <span v-else class="app-subtle-text text-xs">未保存</span>-->
<span class="flex-1"/>
<!-- <span class="flex-1"/>-->
<span class="app-muted-text text-xs">{{ wordCount.toLocaleString() }} </span>
<span class="app-divider w-px h-3"/>
<span class="app-muted-text text-xs">{{ charCount.toLocaleString() }} </span>
</div>
</footer>
<!-- <span class="app-muted-text text-xs">{{ wordCount.toLocaleString() }} 词</span>-->
<!-- <span class="app-divider w-px h-3"/>-->
<!-- <span class="app-muted-text text-xs">{{ charCount.toLocaleString() }} </span>-->
<!-- </div>-->
<!-- </footer>-->
</div>
</template>
@@ -1030,5 +1188,3 @@ registerTauriListener("menu-event", (event) => {
outline: none;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import {ref, computed, watch} from "vue";
import {ref, computed, onBeforeUnmount, watch} from "vue";
import SettingsModal from "./SettingsModal.vue";
import TreeEntry from "./TreeEntry.vue";
@@ -14,6 +14,11 @@ interface DirEntry {
children?: DirEntry[];
}
interface WorkspaceEntry {
name: string;
path: string;
}
const props = withDefaults(
defineProps<{
folderPath: string;
@@ -23,6 +28,7 @@ const props = withDefaults(
settingsInitialPage?: SettingsPage;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
recentWorkspaces?: WorkspaceEntry[];
}>(),
{
currentFilePath: null,
@@ -30,11 +36,14 @@ const props = withDefaults(
settingsInitialPage: "about",
defaultDocumentMode: "edit",
appTheme: "warm",
recentWorkspaces: () => [],
},
);
const emit = defineEmits<{
openFolder: [];
switchWorkspace: [path: string];
removeWorkspace: [path: string];
toggleFolder: [path: string];
openFile: [path: string];
createFile: [];
@@ -69,6 +78,170 @@ function switchView(view: ViewMode) {
}
}
// ---- workspace switcher ----
const workspaceSwitcherRef = ref<HTMLElement | null>(null);
const showWorkspaceMenu = ref(false);
const showWorkspaceManager = ref(false);
const pendingRemovalWorkspace = ref<WorkspaceEntry | null>(null);
function normalizeWorkspacePath(path: string) {
const trimmed = path.trim();
if (!trimmed) return "";
const normalized = trimmed.replace(/[\\/]+$/, "");
if (!normalized) return trimmed[0] || "";
if (/^[A-Za-z]:$/.test(normalized) && /^[A-Za-z]:[\\/]+$/.test(trimmed)) {
return `${normalized}\\`;
}
return normalized;
}
function getWorkspaceIdentity(path: string) {
return normalizeWorkspacePath(path).toLocaleLowerCase();
}
function getWorkspaceName(path: string) {
const normalized = normalizeWorkspacePath(path);
return normalized.split(/[/\\]/).pop() || normalized || path;
}
function getWorkspaceParent(path: string) {
const normalized = normalizeWorkspacePath(path);
const parts = normalized.split(/[/\\]/).filter(Boolean);
if (parts.length <= 1) return normalized;
return parts.slice(0, -1).join("\\");
}
function isCurrentWorkspace(path: string) {
return Boolean(props.folderPath) && getWorkspaceIdentity(props.folderPath) === getWorkspaceIdentity(path);
}
const currentWorkspaceName = computed(() => {
return props.folderPath ? getWorkspaceName(props.folderPath) : "未选择工作目录";
});
const currentWorkspacePath = computed(() => {
return props.folderPath || "打开或选择一个工作目录";
});
const workspaceMenuItems = computed<WorkspaceEntry[]>(() => {
const seen = new Set<string>();
const workspaces: WorkspaceEntry[] = [];
const addWorkspace = (workspace: WorkspaceEntry) => {
const normalized = normalizeWorkspacePath(workspace.path);
if (!normalized) return;
const identity = getWorkspaceIdentity(normalized);
if (seen.has(identity)) return;
seen.add(identity);
workspaces.push({
name: workspace.name || getWorkspaceName(normalized),
path: normalized,
});
};
if (props.folderPath) {
addWorkspace({
name: getWorkspaceName(props.folderPath),
path: props.folderPath,
});
}
props.recentWorkspaces.forEach(addWorkspace);
return workspaces;
});
const isRemovingCurrentWorkspace = computed(() => {
return Boolean(
pendingRemovalWorkspace.value &&
isCurrentWorkspace(pendingRemovalWorkspace.value.path),
);
});
function toggleWorkspaceMenu() {
showWorkspaceMenu.value = !showWorkspaceMenu.value;
}
function closeWorkspaceMenu() {
showWorkspaceMenu.value = false;
}
function onDocumentClick(event: MouseEvent) {
const target = event.target;
if (target instanceof Node && workspaceSwitcherRef.value?.contains(target)) return;
closeWorkspaceMenu();
}
function openFolderFromMenu() {
closeWorkspaceMenu();
emit("openFolder");
}
function selectWorkspace(path: string) {
closeWorkspaceMenu();
if (isCurrentWorkspace(path)) return;
emit("switchWorkspace", path);
}
function openWorkspaceManager() {
closeWorkspaceMenu();
showWorkspaceManager.value = true;
}
function closeWorkspaceManager() {
showWorkspaceManager.value = false;
}
function openFolderFromManager() {
closeWorkspaceManager();
emit("openFolder");
}
function selectWorkspaceFromManager(path: string) {
closeWorkspaceManager();
if (isCurrentWorkspace(path)) return;
emit("switchWorkspace", path);
}
function requestRemoveWorkspace(path: string) {
const normalized = normalizeWorkspacePath(path);
pendingRemovalWorkspace.value = {
name: getWorkspaceName(normalized),
path: normalized,
};
}
function cancelRemoveWorkspace() {
pendingRemovalWorkspace.value = null;
}
function confirmRemoveWorkspace() {
const workspace = pendingRemovalWorkspace.value;
if (!workspace) return;
pendingRemovalWorkspace.value = null;
emit("removeWorkspace", workspace.path);
}
watch(showWorkspaceMenu, (open) => {
if (open) {
document.addEventListener("click", onDocumentClick);
} else {
document.removeEventListener("click", onDocumentClick);
}
});
watch(() => props.folderPath, () => {
closeWorkspaceMenu();
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
});
// ---- settings modal ----
const showSettings = ref(false);
const settingsPage = ref<SettingsPage>("about");
@@ -170,6 +343,75 @@ function openSettings(page: SettingsPage) {
</p>
</div>
</div>
<!-- ===== WORKSPACE SWITCHER ===== -->
<div ref="workspaceSwitcherRef" class="workspace-switcher">
<Transition name="workspace-menu-pop">
<div
v-if="showWorkspaceMenu"
class="workspace-menu"
role="menu"
aria-label="工作目录"
@click.stop
>
<div v-if="workspaceMenuItems.length" class="workspace-menu-list">
<button
v-for="workspace in workspaceMenuItems"
:key="workspace.path"
class="workspace-menu-item"
:class="{ 'workspace-menu-item-active': isCurrentWorkspace(workspace.path) }"
type="button"
role="menuitem"
:title="workspace.path"
@click="selectWorkspace(workspace.path)"
>
<span class="workspace-menu-item-copy">
<span class="workspace-menu-item-name">{{ workspace.name }}</span>
<span class="workspace-menu-item-path">{{ getWorkspaceParent(workspace.path) }}</span>
</span>
<i v-if="isCurrentWorkspace(workspace.path)" class="ri-check-line workspace-menu-check"></i>
</button>
</div>
<div v-else class="workspace-menu-empty">
暂无工作目录
</div>
<div class="workspace-menu-separator"></div>
<button
class="workspace-menu-command"
type="button"
role="menuitem"
@click="openFolderFromMenu"
>
<i class="ri-folder-open-line"></i>
<span>打开工作目录</span>
</button>
<button
class="workspace-menu-command"
type="button"
role="menuitem"
@click="openWorkspaceManager"
>
<i class="ri-book-shelf-line"></i>
<span>管理工作目录</span>
</button>
</div>
</Transition>
<button
class="workspace-trigger"
type="button"
:title="currentWorkspacePath"
aria-haspopup="menu"
:aria-expanded="showWorkspaceMenu"
@click.stop="toggleWorkspaceMenu"
>
<i class="ri-arrow-up-down-line workspace-trigger-icon"></i>
<span class="workspace-trigger-name">{{ currentWorkspaceName }}</span>
<i class="ri-arrow-down-s-line workspace-trigger-caret"></i>
</button>
</div>
</div>
</aside>
@@ -181,6 +423,142 @@ function openSettings(page: SettingsPage) {
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
/>
<Teleport to="body">
<div
v-if="showWorkspaceManager"
class="workspace-manager-overlay"
role="presentation"
@click.self="closeWorkspaceManager"
>
<section
class="workspace-manager-shell"
role="dialog"
aria-modal="true"
aria-label="管理工作目录"
>
<header class="workspace-manager-header">
<div class="workspace-manager-title">
<h2>管理工作目录</h2>
<p>{{ currentWorkspaceName }}</p>
</div>
<button
class="workspace-manager-close"
type="button"
title="关闭"
aria-label="关闭"
@click="closeWorkspaceManager"
>
<i class="ri-close-line"></i>
</button>
</header>
<div class="workspace-manager-body">
<div class="workspace-manager-list" aria-label="最近工作目录">
<div v-if="!workspaceMenuItems.length" class="workspace-manager-empty">
暂无工作目录
</div>
<div
v-for="workspace in workspaceMenuItems"
:key="workspace.path"
class="workspace-manager-row"
:class="{ 'workspace-manager-row-current': isCurrentWorkspace(workspace.path) }"
>
<div class="workspace-manager-row-main">
<i class="ri-folder-line"></i>
<div class="workspace-manager-row-copy">
<strong>{{ workspace.name }}</strong>
<span>{{ workspace.path }}</span>
</div>
</div>
<div class="workspace-manager-row-actions">
<span v-if="isCurrentWorkspace(workspace.path)" class="workspace-manager-current-label">
当前
</span>
<button
v-else
class="workspace-manager-icon-btn"
type="button"
title="打开"
aria-label="打开"
@click="selectWorkspaceFromManager(workspace.path)"
>
<i class="ri-arrow-right-line"></i>
</button>
<button
class="workspace-manager-icon-btn"
type="button"
title="移除"
aria-label="移除"
@click="requestRemoveWorkspace(workspace.path)"
>
<i class="ri-delete-bin-line"></i>
</button>
</div>
</div>
</div>
<footer class="workspace-manager-actions">
<button
class="workspace-manager-primary"
type="button"
@click="openFolderFromManager"
>
<i class="ri-folder-open-line"></i>
<span>打开本地工作目录</span>
</button>
</footer>
</div>
</section>
</div>
<div
v-if="pendingRemovalWorkspace"
class="workspace-remove-confirm-overlay"
role="presentation"
@click.self="cancelRemoveWorkspace"
>
<section
class="workspace-remove-confirm-shell"
role="dialog"
aria-modal="true"
aria-labelledby="workspace-remove-confirm-title"
>
<header class="workspace-remove-confirm-header">
<i class="ri-error-warning-line"></i>
<h3 id="workspace-remove-confirm-title">移除工作目录</h3>
</header>
<div class="workspace-remove-confirm-body">
<p>
确定要从列表中移除“{{ pendingRemovalWorkspace.name }}”吗?
</p>
<p>
这只会移除工作目录记录,不会删除本地文件。
</p>
<p v-if="isRemovingCurrentWorkspace">
当前工作目录会被关闭,编辑区域会被重置。
</p>
</div>
<footer class="workspace-remove-confirm-actions">
<button
class="workspace-remove-confirm-cancel"
type="button"
@click="cancelRemoveWorkspace"
>
取消
</button>
<button
class="workspace-remove-confirm-danger"
type="button"
@click="confirmRemoveWorkspace"
>
移除
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
@@ -232,4 +610,562 @@ function openSettings(page: SettingsPage) {
.sidebar-search-input::placeholder {
color: var(--app-subtle);
}
.workspace-switcher {
position: relative;
z-index: 20;
padding: 6px;
border-top: 1px solid var(--app-border);
background: color-mix(in srgb, var(--app-sidebar-bg) 88%, transparent);
}
.workspace-trigger {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px;
align-items: center;
gap: 6px;
width: 100%;
height: 32px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--app-text-soft);
cursor: pointer;
font-size: 12px;
line-height: 1;
text-align: left;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.workspace-trigger:hover,
.workspace-trigger[aria-expanded="true"] {
border-color: var(--app-border-strong);
background: var(--app-hover);
color: var(--app-text);
}
.workspace-trigger-icon,
.workspace-trigger-caret {
color: var(--app-subtle);
font-size: 14px;
}
.workspace-trigger-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.workspace-menu-pop-enter-active,
.workspace-menu-pop-leave-active {
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.workspace-menu-pop-enter-from,
.workspace-menu-pop-leave-to {
opacity: 0;
transform: translateY(4px);
}
.workspace-menu {
position: absolute;
right: 6px;
bottom: calc(100% + 6px);
left: 6px;
overflow: hidden;
border: 1px solid var(--app-border-strong);
border-radius: 8px;
background: var(--app-surface);
box-shadow:
0 14px 34px color-mix(in srgb, var(--app-shadow) 70%, transparent),
0 1px 4px color-mix(in srgb, var(--app-shadow) 70%, transparent);
}
.workspace-menu-list {
max-height: 210px;
overflow-y: auto;
padding: 4px;
}
.workspace-menu-item,
.workspace-menu-command {
display: flex;
width: 100%;
align-items: center;
border: none;
background: transparent;
color: var(--app-text-soft);
cursor: pointer;
text-align: left;
}
.workspace-menu-item {
min-height: 42px;
gap: 8px;
justify-content: space-between;
padding: 6px 8px;
border-radius: 6px;
}
.workspace-menu-item:hover,
.workspace-menu-command:hover {
background: var(--app-hover);
color: var(--app-text);
}
.workspace-menu-item-active {
color: var(--app-text-strong);
font-weight: 600;
}
.workspace-menu-item-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.workspace-menu-item-name,
.workspace-menu-item-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-menu-item-name {
font-size: 13px;
line-height: 1.1;
}
.workspace-menu-item-path {
color: var(--app-subtle);
font-size: 11px;
font-weight: 400;
line-height: 1.1;
}
.workspace-menu-check {
flex-shrink: 0;
color: var(--app-accent);
font-size: 15px;
}
.workspace-menu-empty {
padding: 12px 10px 10px;
color: var(--app-subtle);
font-size: 12px;
text-align: center;
}
.workspace-menu-separator {
height: 1px;
background: var(--app-border);
}
.workspace-menu-command {
gap: 8px;
min-height: 34px;
padding: 0 10px;
font-size: 13px;
}
.workspace-menu-command i {
color: var(--app-muted);
font-size: 15px;
}
.workspace-manager-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: var(--app-overlay);
backdrop-filter: blur(8px);
}
.workspace-manager-shell {
display: flex;
width: min(720px, calc(100vw - 48px));
max-height: min(620px, calc(100vh - 48px));
min-height: 390px;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--app-border-strong);
border-radius: 12px;
background: var(--app-surface);
box-shadow:
0 24px 70px var(--app-shadow),
0 2px 10px color-mix(in srgb, var(--app-shadow) 46%, transparent);
}
.workspace-manager-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 66px;
padding: 0 18px 0 22px;
border-bottom: 1px solid var(--app-border);
background: color-mix(in srgb, var(--app-surface) 72%, transparent);
}
.workspace-manager-title {
min-width: 0;
}
.workspace-manager-title h2 {
margin: 0 0 5px;
color: var(--app-text-strong);
font-size: 16px;
font-weight: 700;
line-height: 1.1;
}
.workspace-manager-title p {
margin: 0;
overflow: hidden;
color: var(--app-muted);
font-size: 12px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-manager-close,
.workspace-manager-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--app-muted);
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.workspace-manager-close {
width: 30px;
height: 30px;
border-radius: 6px;
font-size: 17px;
}
.workspace-manager-close:hover,
.workspace-manager-icon-btn:hover:not(:disabled) {
background: var(--app-hover);
color: var(--app-text);
}
.workspace-manager-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
background: var(--app-panel-bg);
}
.workspace-manager-list {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 14px;
}
.workspace-manager-empty {
display: flex;
min-height: 190px;
align-items: center;
justify-content: center;
color: var(--app-subtle);
font-size: 13px;
}
.workspace-manager-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
min-height: 62px;
padding: 10px 10px 10px 12px;
border-radius: 8px;
color: var(--app-text-soft);
}
.workspace-manager-row + .workspace-manager-row {
margin-top: 4px;
}
.workspace-manager-row:hover {
background: var(--app-hover);
}
.workspace-manager-row-current {
background: var(--app-active-bg);
color: var(--app-text);
}
.workspace-manager-row-main {
display: flex;
min-width: 0;
align-items: center;
gap: 11px;
}
.workspace-manager-row-main > i {
flex-shrink: 0;
color: color-mix(in srgb, var(--app-accent) 76%, transparent);
font-size: 18px;
}
.workspace-manager-row-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.workspace-manager-row-copy strong,
.workspace-manager-row-copy span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-manager-row-copy strong {
color: var(--app-text-strong);
font-size: 14px;
font-weight: 700;
}
.workspace-manager-row-copy span {
color: var(--app-muted);
font-size: 12px;
}
.workspace-manager-row-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.workspace-manager-current-label {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--app-accent) 12%, transparent);
color: var(--app-accent);
font-size: 12px;
font-weight: 600;
}
.workspace-manager-icon-btn {
width: 30px;
height: 30px;
border-radius: 6px;
font-size: 15px;
}
.workspace-manager-icon-btn:disabled {
cursor: default;
opacity: 0.34;
}
.workspace-manager-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
min-height: 62px;
padding: 12px 16px;
border-top: 1px solid var(--app-border);
background: var(--app-surface);
}
.workspace-manager-primary {
display: inline-flex;
gap: 8px;
min-height: 32px;
min-width: 142px;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: var(--app-control);
color: var(--app-control-contrast);
font-size: 13px;
font-weight: 600;
line-height: 1;
box-shadow: 0 2px 7px color-mix(in srgb, var(--app-control) 32%, transparent);
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease,
box-shadow 0.15s ease;
}
.workspace-manager-primary:hover {
background: var(--app-control-hover);
}
.workspace-remove-confirm-overlay {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: color-mix(in srgb, var(--app-overlay) 86%, transparent);
backdrop-filter: blur(10px);
}
.workspace-remove-confirm-shell {
width: min(420px, calc(100vw - 40px));
overflow: hidden;
border: 1px solid var(--app-border-strong);
border-radius: 10px;
background: var(--app-surface);
box-shadow:
0 22px 56px var(--app-shadow),
0 2px 10px color-mix(in srgb, var(--app-shadow) 45%, transparent);
}
.workspace-remove-confirm-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 18px;
border-bottom: 1px solid var(--app-border);
}
.workspace-remove-confirm-header i {
color: #d97706;
font-size: 19px;
}
.workspace-remove-confirm-header h3 {
margin: 0;
color: var(--app-text-strong);
font-size: 15px;
font-weight: 700;
line-height: 1.2;
}
.workspace-remove-confirm-body {
padding: 16px 18px 4px;
}
.workspace-remove-confirm-body p {
margin: 0 0 10px;
color: var(--app-text-soft);
font-size: 13px;
line-height: 1.55;
}
.workspace-remove-confirm-body p:first-child {
color: var(--app-text);
}
.workspace-remove-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px 16px;
}
.workspace-remove-confirm-cancel,
.workspace-remove-confirm-danger {
display: inline-flex;
min-width: 74px;
min-height: 32px;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.workspace-remove-confirm-cancel {
border: 1px solid var(--app-border-strong);
background: var(--app-surface);
color: var(--app-text-soft);
}
.workspace-remove-confirm-cancel:hover {
background: var(--app-hover);
color: var(--app-text);
}
.workspace-remove-confirm-danger {
border: 1px solid #dc2626;
background: #dc2626;
color: #ffffff;
}
.workspace-remove-confirm-danger:hover {
border-color: #b91c1c;
background: #b91c1c;
}
@media (max-width: 680px) {
.workspace-manager-overlay {
align-items: stretch;
padding: 14px;
}
.workspace-manager-shell {
width: 100%;
max-height: none;
}
.workspace-manager-row {
grid-template-columns: 1fr;
gap: 8px;
}
.workspace-manager-row-actions {
justify-content: flex-end;
}
.workspace-manager-actions {
align-items: stretch;
flex-direction: column;
}
.workspace-manager-primary {
width: 100%;
}
.workspace-remove-confirm-overlay {
align-items: flex-end;
padding: 14px;
}
.workspace-remove-confirm-shell {
width: 100%;
}
}
</style>