添加切换工作目录功能
This commit is contained in:
226
src/App.vue
226
src/App.vue
@@ -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 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();
|
||||
}
|
||||
|
||||
async function switchWorkspace(path: string, options: { persist?: boolean; resetEditor?: boolean } = {}) {
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
|
||||
if (options.resetEditor !== false) {
|
||||
resetMarkdownEditorState();
|
||||
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
|
||||
if (!saved) return false;
|
||||
resetWorkspaceAndEditorState();
|
||||
}
|
||||
|
||||
const loaded = await loadDir(path);
|
||||
const loaded = await loadDir(normalized);
|
||||
|
||||
if (loaded && options.persist !== false) {
|
||||
saveLastWorkspacePath(path);
|
||||
} else if (!loaded) {
|
||||
clearLastWorkspacePath();
|
||||
if (!loaded) {
|
||||
forgetWorkspace(normalized);
|
||||
|
||||
const lastWorkspacePath = getLastWorkspacePath();
|
||||
if (lastWorkspacePath && getWorkspaceIdentity(lastWorkspacePath) === getWorkspaceIdentity(normalized)) {
|
||||
clearLastWorkspacePath();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return loaded;
|
||||
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>
|
||||
<!-- <!– 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>-->
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user