1712 lines
46 KiB
Vue
1712 lines
46 KiB
Vue
<script setup lang="ts">
|
|
import {ref, computed, onBeforeUnmount, watch, nextTick} from "vue";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import SettingsModal from "./SettingsModal.vue";
|
|
import TreeEntry from "./TreeEntry.vue";
|
|
|
|
type SettingsPage = "about" | "editor";
|
|
type DocumentMode = "preview" | "edit" | "mixed";
|
|
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
|
|
|
interface DirEntry {
|
|
name: string;
|
|
path: string;
|
|
is_dir: boolean;
|
|
children?: DirEntry[];
|
|
}
|
|
|
|
interface WorkspaceEntry {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
folderPath: string;
|
|
entries: DirEntry[];
|
|
currentFilePath?: string | null;
|
|
settingsRequest?: number;
|
|
settingsInitialPage?: SettingsPage;
|
|
defaultDocumentMode?: DocumentMode;
|
|
appTheme?: AppTheme;
|
|
recentWorkspaces?: WorkspaceEntry[];
|
|
}>(),
|
|
{
|
|
currentFilePath: null,
|
|
settingsRequest: 0,
|
|
settingsInitialPage: "about",
|
|
defaultDocumentMode: "edit",
|
|
appTheme: "warm",
|
|
recentWorkspaces: () => [],
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
openFolder: [];
|
|
switchWorkspace: [path: string];
|
|
removeWorkspace: [path: string];
|
|
toggleFolder: [path: string];
|
|
openFile: [path: string];
|
|
createFile: [path: string];
|
|
createFolder: [path: string];
|
|
deleteEntry: [path: string];
|
|
renameEntry: [path: string, newName: string];
|
|
updateDefaultDocumentMode: [mode: DocumentMode];
|
|
updateAppTheme: [theme: AppTheme];
|
|
"search-result-click": [filePath: string, lineNumber: number];
|
|
}>();
|
|
|
|
// ---- sidebar view mode ----
|
|
type ViewMode = "folder" | "search" | "setting";
|
|
const activeView = ref<ViewMode>("folder");
|
|
|
|
// ---- search types ----
|
|
interface SearchMatch {
|
|
line: number;
|
|
column: number;
|
|
line_text: string;
|
|
context_before: string[];
|
|
context_after: string[];
|
|
}
|
|
|
|
interface FileSearchResult {
|
|
relative_path: string;
|
|
absolute_path: string;
|
|
matches: SearchMatch[];
|
|
match_count: number;
|
|
}
|
|
|
|
const SEARCH_HISTORY_KEY = "yurou:search-history";
|
|
const MAX_SEARCH_HISTORY = 10;
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
|
|
const searchKeyword = ref("");
|
|
const searchCaseSensitive = ref(false);
|
|
const searchUseRegex = ref(false);
|
|
const searchWholeWord = ref(false);
|
|
const searchExtensions = ref<string[]>(["md"]);
|
|
const searchResults = ref<FileSearchResult[]>([]);
|
|
const searchLoading = ref(false);
|
|
const searchError = ref("");
|
|
const searchHistory = ref<string[]>(loadSearchHistory());
|
|
const searchInputRef = ref<HTMLInputElement | null>(null);
|
|
const searchFocusedFile = ref<string | null>(null);
|
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const searchableTypes = [
|
|
{ label: "Markdown (.md)", value: "md" },
|
|
{ label: "纯文本 (.txt)", value: "txt" },
|
|
{ label: "JSON (.json)", value: "json" },
|
|
{ label: "YAML (.yaml/.yml)", value: "yaml" },
|
|
{ label: "JavaScript (.js)", value: "js" },
|
|
{ label: "TypeScript (.ts)", value: "ts" },
|
|
{ label: "CSS (.css)", value: "css" },
|
|
{ label: "HTML (.html)", value: "html" },
|
|
{ label: "XML (.xml)", value: "xml" },
|
|
];
|
|
|
|
const showExtensionDropdown = ref(false);
|
|
const extensionDropdownRef = ref<HTMLElement | null>(null);
|
|
|
|
const totalMatchCount = computed(() => {
|
|
return searchResults.value.reduce((sum, f) => sum + f.match_count, 0);
|
|
});
|
|
|
|
function loadSearchHistory(): string[] {
|
|
try {
|
|
const stored = localStorage.getItem(SEARCH_HISTORY_KEY);
|
|
return stored ? JSON.parse(stored) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveSearchHistory() {
|
|
try {
|
|
const keyword = searchKeyword.value.trim();
|
|
if (!keyword) return;
|
|
const history = searchHistory.value.filter((h) => h !== keyword);
|
|
history.unshift(keyword);
|
|
const trimmed = history.slice(0, MAX_SEARCH_HISTORY);
|
|
searchHistory.value = trimmed;
|
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(trimmed));
|
|
} catch { /* noop */ }
|
|
}
|
|
|
|
async function performSearch() {
|
|
const keyword = searchKeyword.value.trim();
|
|
if (!keyword || !props.folderPath) {
|
|
searchResults.value = [];
|
|
searchError.value = "";
|
|
return;
|
|
}
|
|
|
|
searchLoading.value = true;
|
|
searchError.value = "";
|
|
|
|
try {
|
|
const results = await invoke<FileSearchResult[]>("search_files", {
|
|
workspace: props.folderPath,
|
|
keyword,
|
|
caseSensitive: searchCaseSensitive.value,
|
|
useRegex: searchUseRegex.value,
|
|
wholeWord: searchWholeWord.value,
|
|
extensions: searchExtensions.value,
|
|
});
|
|
searchResults.value = results;
|
|
if (results.length === 0) {
|
|
searchError.value = "未找到匹配结果";
|
|
}
|
|
} catch (e) {
|
|
searchError.value = `搜索出错: ${e}`;
|
|
searchResults.value = [];
|
|
} finally {
|
|
searchLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function triggerSearch() {
|
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
|
searchDebounceTimer = setTimeout(() => {
|
|
void performSearch();
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
}
|
|
|
|
function clearSearch() {
|
|
searchKeyword.value = "";
|
|
searchResults.value = [];
|
|
searchError.value = "";
|
|
searchFocusedFile.value = null;
|
|
}
|
|
|
|
function toggleExtension(ext: string) {
|
|
const idx = searchExtensions.value.indexOf(ext);
|
|
if (idx >= 0) {
|
|
if (searchExtensions.value.length > 1) {
|
|
searchExtensions.value.splice(idx, 1);
|
|
}
|
|
} else {
|
|
searchExtensions.value = [...searchExtensions.value, ext];
|
|
}
|
|
triggerSearch();
|
|
}
|
|
|
|
function handleSearchKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") {
|
|
clearSearch();
|
|
} else if (e.key === "Enter" && searchResults.value.length > 0) {
|
|
const first = searchResults.value[0];
|
|
if (first.matches.length > 0) {
|
|
saveSearchHistory();
|
|
emit("search-result-click", first.absolute_path, first.matches[0].line);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onResultClick(file: FileSearchResult, match: SearchMatch) {
|
|
saveSearchHistory();
|
|
emit("search-result-click", file.absolute_path, match.line);
|
|
}
|
|
|
|
function toggleFileCollapse(relativePath: string) {
|
|
searchFocusedFile.value = searchFocusedFile.value === relativePath ? null : relativePath;
|
|
}
|
|
|
|
function focusSearchInput() {
|
|
void nextTick(() => {
|
|
searchInputRef.value?.focus();
|
|
searchInputRef.value?.select();
|
|
});
|
|
}
|
|
|
|
function activateSearch() {
|
|
if (activeView.value !== "search") {
|
|
activeView.value = "search";
|
|
if (isCollapsed.value) expand();
|
|
}
|
|
focusSearchInput();
|
|
}
|
|
|
|
function highlightMatchText(text: string): string {
|
|
const keyword = searchKeyword.value.trim();
|
|
if (!keyword || searchUseRegex.value) {
|
|
return escapeHtml(text);
|
|
}
|
|
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
try {
|
|
const flags = searchCaseSensitive.value ? "g" : "gi";
|
|
const re = new RegExp(`(${escaped})`, flags);
|
|
return escapeHtml(text).replace(re, "<mark class='search-highlight'>$1</mark>");
|
|
} catch {
|
|
return escapeHtml(text);
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
function closeExtensionDropdown(e: MouseEvent) {
|
|
if (extensionDropdownRef.value && !extensionDropdownRef.value.contains(e.target as Node)) {
|
|
showExtensionDropdown.value = false;
|
|
}
|
|
}
|
|
|
|
watch(showExtensionDropdown, (open) => {
|
|
if (open) {
|
|
setTimeout(() => document.addEventListener("click", closeExtensionDropdown), 0);
|
|
} else {
|
|
document.removeEventListener("click", closeExtensionDropdown);
|
|
}
|
|
});
|
|
|
|
watch([searchKeyword, searchCaseSensitive, searchUseRegex, searchWholeWord], () => {
|
|
triggerSearch();
|
|
});
|
|
|
|
// collapsed computed is defined above (line ~71), reuse it
|
|
defineExpose({
|
|
activateSearch,
|
|
});
|
|
|
|
// ---- sidebar resize ----
|
|
const MIN_SIDEBAR_WIDTH = 180;
|
|
const MAX_SIDEBAR_WIDTH = 500;
|
|
const COLLAPSED_WIDTH = 48;
|
|
const DEFAULT_EXPANDED_WIDTH = 288;
|
|
|
|
const sidebarWidth = ref(DEFAULT_EXPANDED_WIDTH);
|
|
const lastExpandedWidth = ref(DEFAULT_EXPANDED_WIDTH);
|
|
const isResizing = ref(false);
|
|
const isCollapsed = computed(() => sidebarWidth.value <= COLLAPSED_WIDTH);
|
|
|
|
function expand() {
|
|
sidebarWidth.value = lastExpandedWidth.value;
|
|
}
|
|
|
|
function collapse() {
|
|
sidebarWidth.value = COLLAPSED_WIDTH;
|
|
}
|
|
|
|
function toggleCollapse() {
|
|
if (isCollapsed.value) {
|
|
expand();
|
|
} else {
|
|
collapse();
|
|
}
|
|
}
|
|
|
|
function switchView(view: ViewMode) {
|
|
if (activeView.value === view) {
|
|
toggleCollapse();
|
|
} else {
|
|
activeView.value = view;
|
|
if (isCollapsed.value) {
|
|
expand();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- resize handle drag ----
|
|
let resizeStartX = 0;
|
|
let resizeStartWidth = 0;
|
|
|
|
function startResize(e: MouseEvent) {
|
|
resizeStartX = e.clientX;
|
|
resizeStartWidth = sidebarWidth.value;
|
|
isResizing.value = true;
|
|
document.addEventListener("mousemove", onResize);
|
|
document.addEventListener("mouseup", stopResize);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
e.preventDefault();
|
|
}
|
|
|
|
function onResize(e: MouseEvent) {
|
|
if (!isResizing.value) return;
|
|
const delta = e.clientX - resizeStartX;
|
|
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, resizeStartWidth + delta));
|
|
sidebarWidth.value = newWidth;
|
|
lastExpandedWidth.value = newWidth;
|
|
}
|
|
|
|
function stopResize() {
|
|
isResizing.value = false;
|
|
document.removeEventListener("mousemove", onResize);
|
|
document.removeEventListener("mouseup", stopResize);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
}
|
|
|
|
// ---- 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);
|
|
document.removeEventListener("mousemove", onResize);
|
|
document.removeEventListener("mouseup", stopResize);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
});
|
|
|
|
// ---- settings modal ----
|
|
const showSettings = ref(false);
|
|
const settingsPage = ref<SettingsPage>("about");
|
|
|
|
watch(() => props.settingsRequest, (request) => {
|
|
if (request <= 0) return;
|
|
openSettings(props.settingsInitialPage);
|
|
});
|
|
|
|
function openSettings(page: SettingsPage) {
|
|
settingsPage.value = page;
|
|
showSettings.value = true;
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<aside
|
|
class="directory-sidebar flex flex-row overflow-hidden shrink-0"
|
|
:class="{ 'transition-all duration-300 ease-in-out': !isResizing }"
|
|
:style="{ width: sidebarWidth + 'px' }"
|
|
>
|
|
<!-- ============ Icon bar ============ -->
|
|
<div class="w-12 shrink-0 flex flex-col items-center gap-1 py-3">
|
|
<!-- Folder -->
|
|
<button
|
|
class="sidebar-icon-btn px-2 py-1 cursor-pointer rounded-md transition-colors"
|
|
:class="{ 'sidebar-icon-btn-active': activeView === 'folder' }"
|
|
title="文件"
|
|
@click="switchView('folder')"
|
|
>
|
|
<i class="ri-folder-line text-base"></i>
|
|
</button>
|
|
<!-- Search -->
|
|
<button
|
|
class="sidebar-icon-btn px-2 py-1 cursor-pointer rounded-md transition-colors"
|
|
:class="{ 'sidebar-icon-btn-active': activeView === 'search' }"
|
|
title="搜索"
|
|
@click="switchView('search')"
|
|
>
|
|
<i class="ri-search-line text-base"></i>
|
|
</button>
|
|
<!-- ===== Settings ===== -->
|
|
<button
|
|
class="sidebar-icon-btn px-2 py-1 cursor-pointer rounded-md transition-colors mt-auto"
|
|
:class="{ 'sidebar-icon-btn-active': showSettings }"
|
|
title="设置"
|
|
@click="openSettings('about')"
|
|
>
|
|
<i class="ri-settings-line text-sm"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ============ Expanded panel ============ -->
|
|
<div v-show="!isCollapsed" class="directory-sidebar-panel flex-1 flex flex-col overflow-hidden">
|
|
<!-- ===== FOLDER VIEW ===== -->
|
|
<div v-if="activeView === 'folder'" class="flex flex-col flex-1 overflow-hidden">
|
|
<!-- File tree -->
|
|
<div
|
|
class="flex-1 overflow-y-auto px-2 py-2 flex flex-col gap-0.5"
|
|
|
|
>
|
|
<TreeEntry
|
|
v-for="entry in entries"
|
|
:key="entry.path"
|
|
:entry="entry"
|
|
:depth="0"
|
|
:currentFilePath="currentFilePath"
|
|
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
|
@open-file="(path: string) => emit('openFile', path)"
|
|
@create-folder="(path: string) => emit('createFolder', path)"
|
|
@create-file="(path: string) => emit('createFile', path)"
|
|
@delete-entry="(path: string) => emit('deleteEntry', path)"
|
|
@rename-entry="(path: string, name: string) => emit('renameEntry', path, name)"
|
|
/>
|
|
|
|
<!-- Empty state -->
|
|
<div
|
|
v-if="!folderPath"
|
|
class="sidebar-empty px-2 py-4 text-xs text-center"
|
|
>
|
|
<p class="mb-3">暂无打开的文件夹</p>
|
|
<button
|
|
class="sidebar-empty-btn inline-flex items-center gap-1.5 px-3 py-1.5 text-xs cursor-pointer rounded-md transition-colors"
|
|
@click="$emit('openFolder')"
|
|
>
|
|
<i class="ri-folder-open-line"></i>
|
|
打开文件夹
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== SEARCH VIEW ===== -->
|
|
<div v-if="activeView === 'search'" class="flex flex-col flex-1 overflow-hidden">
|
|
<!-- 搜索输入区 -->
|
|
<div class="px-3 py-3">
|
|
<div class="relative">
|
|
<i class="sidebar-search-icon ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-sm"></i>
|
|
<input
|
|
ref="searchInputRef"
|
|
v-model="searchKeyword"
|
|
class="sidebar-search-input w-full pl-8 pr-8 py-1.5 text-xs rounded-md outline-none"
|
|
placeholder="搜索文件内容..."
|
|
@keydown="handleSearchKeydown"
|
|
/>
|
|
<button
|
|
v-if="searchKeyword"
|
|
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-xs cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 text-[var(--app-subtle)]"
|
|
title="清空"
|
|
@click="clearSearch"
|
|
>
|
|
<i class="ri-close-line"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 搜索选项 -->
|
|
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
|
<button
|
|
class="search-option-btn"
|
|
:class="{ 'search-option-active': searchCaseSensitive }"
|
|
title="区分大小写"
|
|
@click="searchCaseSensitive = !searchCaseSensitive"
|
|
>
|
|
Aa
|
|
</button>
|
|
<button
|
|
class="search-option-btn"
|
|
:class="{ 'search-option-active': searchUseRegex }"
|
|
title="正则表达式"
|
|
@click="searchUseRegex = !searchUseRegex"
|
|
>
|
|
.*
|
|
</button>
|
|
<button
|
|
class="search-option-btn"
|
|
:class="{ 'search-option-active': searchWholeWord }"
|
|
title="全词匹配"
|
|
@click="searchWholeWord = !searchWholeWord"
|
|
>
|
|
<i class="ri-font-size"></i>
|
|
</button>
|
|
|
|
<!-- 文件类型下拉 -->
|
|
<div ref="extensionDropdownRef" class="relative">
|
|
<button
|
|
class="search-option-btn flex items-center gap-1"
|
|
@click="showExtensionDropdown = !showExtensionDropdown"
|
|
title="文件类型"
|
|
>
|
|
<span class="text-[10px]">.{{ searchExtensions.join(', .') }}</span>
|
|
<i class="ri-arrow-down-s-line text-[9px]"></i>
|
|
</button>
|
|
<div
|
|
v-if="showExtensionDropdown"
|
|
class="search-ext-dropdown absolute top-full left-0 mt-1 bg-[var(--app-surface)] border border-[var(--app-border)] rounded-md shadow-lg z-30 py-1 min-w-[150px] max-h-52 overflow-y-auto"
|
|
@click.stop
|
|
>
|
|
<label
|
|
v-for="type in searchableTypes"
|
|
:key="type.value"
|
|
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-[var(--app-hover)]"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
:checked="searchExtensions.includes(type.value)"
|
|
class="w-3 h-3 accent-[var(--app-accent)]"
|
|
@change="toggleExtension(type.value)"
|
|
/>
|
|
{{ type.label }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 搜索结果区域 -->
|
|
<div class="flex-1 overflow-y-auto px-2 pb-2">
|
|
<!-- Loading -->
|
|
<div v-if="searchLoading" class="flex items-center justify-center py-8">
|
|
<p class="text-xs text-[var(--app-subtle)]">搜索中...</p>
|
|
</div>
|
|
|
|
<!-- Error / No results -->
|
|
<div v-else-if="searchError && searchResults.length === 0" class="flex items-center justify-center py-8">
|
|
<p class="text-xs text-[var(--app-subtle)]">{{ searchError }}</p>
|
|
</div>
|
|
|
|
<!-- Empty prompt / history -->
|
|
<div v-else-if="!searchKeyword.trim()" class="flex flex-col py-4 px-1">
|
|
<template v-if="searchHistory.length > 0">
|
|
<p class="text-[10px] text-[var(--app-subtle)] px-2 mb-2 uppercase tracking-wider">最近搜索</p>
|
|
<button
|
|
v-for="(item, idx) in searchHistory.slice(0, 5)"
|
|
:key="idx"
|
|
class="search-history-item flex items-center gap-1.5 text-xs px-2 py-1.5 rounded cursor-pointer hover:bg-[var(--app-hover)] w-full text-left truncate"
|
|
@click="searchKeyword = item"
|
|
>
|
|
<i class="ri-history-line text-[var(--app-subtle)] shrink-0"></i>
|
|
<span class="truncate text-[var(--app-text-soft)]">{{ item }}</span>
|
|
</button>
|
|
</template>
|
|
<div v-else class="flex flex-col items-center justify-center py-8">
|
|
<p v-if="props.folderPath" class="text-xs text-[var(--app-subtle)]">
|
|
输入关键词搜索工作目录中的文件
|
|
</p>
|
|
<p v-else class="text-xs text-[var(--app-subtle)]">
|
|
请先打开一个工作目录
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results list (grouped by file) -->
|
|
<div v-else class="flex flex-col gap-1">
|
|
<div class="text-[10px] text-[var(--app-subtle)] px-1 py-1">
|
|
{{ searchResults.length }} 个文件,共 {{ totalMatchCount }} 处匹配
|
|
</div>
|
|
<div
|
|
v-for="file in searchResults"
|
|
:key="file.absolute_path"
|
|
class="search-file-group"
|
|
>
|
|
<!-- File header -->
|
|
<button
|
|
class="search-file-header w-full flex items-center gap-1.5 px-2 py-1.5 text-xs rounded cursor-pointer hover:bg-[var(--app-hover)] transition-colors"
|
|
@click="toggleFileCollapse(file.relative_path)"
|
|
>
|
|
<i
|
|
class="text-[10px] transition-transform"
|
|
:class="searchFocusedFile === file.relative_path ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'"
|
|
></i>
|
|
<i class="ri-file-text-line text-[var(--app-subtle)] shrink-0"></i>
|
|
<span class="truncate flex-1 text-left text-[var(--app-text-soft)]">{{ file.relative_path }}</span>
|
|
<span class="text-[var(--app-subtle)] shrink-0 text-[10px]">{{ file.match_count }}</span>
|
|
</button>
|
|
|
|
<!-- File matches (collapsible) -->
|
|
<div
|
|
v-show="searchFocusedFile !== file.relative_path"
|
|
class="search-matches ml-5"
|
|
>
|
|
<button
|
|
v-for="(match, mIdx) in file.matches"
|
|
:key="mIdx"
|
|
class="search-match-item w-full text-left px-2 py-1.5 text-xs rounded cursor-pointer hover:bg-[var(--app-hover)] transition-colors block"
|
|
:class="{ 'bg-[var(--app-active-bg)]': props.currentFilePath === file.absolute_path }"
|
|
@click="onResultClick(file, match)"
|
|
>
|
|
<div class="flex items-baseline gap-2 mb-0.5">
|
|
<span class="text-[10px] text-[var(--app-accent)] shrink-0 font-mono">L{{ match.line }}</span>
|
|
<span class="truncate text-[var(--app-text-soft)]" v-html="highlightMatchText(match.line_text)"></span>
|
|
</div>
|
|
<!-- Context lines -->
|
|
<div v-if="match.context_before.length || match.context_after.length" class="text-[10px] text-[var(--app-subtle)] mt-0.5 space-y-0">
|
|
<div v-for="(ctxLine, ci) in match.context_before" :key="'b' + ci" class="truncate opacity-60">
|
|
{{ ctxLine }}
|
|
</div>
|
|
<div v-for="(ctxLine, ci) in match.context_after" :key="'a' + ci" class="truncate opacity-60">
|
|
{{ ctxLine }}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<div v-if="file.match_count > file.matches.length" class="text-[10px] text-[var(--app-subtle)] px-2 py-0.5">
|
|
... 还有 {{ file.match_count - file.matches.length }} 处匹配未显示
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
<!-- ============ Resize handle ============ -->
|
|
<div
|
|
v-show="!isCollapsed"
|
|
class="sidebar-resize-handle"
|
|
:class="{ 'sidebar-resize-handle-active': isResizing }"
|
|
@mousedown="startResize"
|
|
>
|
|
<div class="sidebar-resize-handle-bar"></div>
|
|
</div>
|
|
</aside>
|
|
|
|
<SettingsModal
|
|
v-model:show="showSettings"
|
|
:initial-page="settingsPage"
|
|
:default-document-mode="defaultDocumentMode"
|
|
:app-theme="appTheme"
|
|
@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>
|
|
.directory-sidebar {
|
|
position: relative;
|
|
border-right: 1px solid var(--app-border);
|
|
background: color-mix(in srgb, var(--app-sidebar-bg) 82%, transparent);
|
|
}
|
|
|
|
.directory-sidebar-panel {
|
|
border-left: 1px solid var(--app-border);
|
|
}
|
|
|
|
/* ---- Resize handle ---- */
|
|
.sidebar-resize-handle {
|
|
position: absolute;
|
|
top: 0;
|
|
right: -2px;
|
|
bottom: 0;
|
|
z-index: 25;
|
|
display: flex;
|
|
width: 7px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: col-resize;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.sidebar-resize-handle:hover,
|
|
.sidebar-resize-handle-active {
|
|
background: color-mix(in srgb, var(--app-accent) 18%, transparent);
|
|
}
|
|
|
|
.sidebar-resize-handle-bar {
|
|
width: 1px;
|
|
height: 100%;
|
|
border-radius: 1px;
|
|
background: transparent;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.sidebar-resize-handle:hover .sidebar-resize-handle-bar,
|
|
.sidebar-resize-handle-active .sidebar-resize-handle-bar {
|
|
background: color-mix(in srgb, var(--app-accent) 36%, transparent);
|
|
}
|
|
|
|
.sidebar-icon-btn {
|
|
color: var(--app-subtle);
|
|
}
|
|
|
|
.sidebar-icon-btn:hover,
|
|
.sidebar-icon-btn-active {
|
|
background: var(--app-hover);
|
|
color: var(--app-accent);
|
|
}
|
|
|
|
.sidebar-empty,
|
|
.sidebar-search-icon {
|
|
color: var(--app-subtle);
|
|
}
|
|
|
|
.sidebar-empty-btn {
|
|
border: 1px solid color-mix(in srgb, var(--app-accent-muted) 42%, transparent);
|
|
background: var(--app-active-bg);
|
|
color: var(--app-accent);
|
|
}
|
|
|
|
.sidebar-empty-btn:hover {
|
|
background: color-mix(in srgb, var(--app-active-bg) 84%, var(--app-surface));
|
|
color: var(--app-accent);
|
|
}
|
|
|
|
.sidebar-search-input {
|
|
border: 1px solid var(--app-border-strong);
|
|
background: var(--app-hover);
|
|
color: var(--app-text);
|
|
}
|
|
|
|
.sidebar-search-input:focus {
|
|
border-color: var(--app-accent);
|
|
}
|
|
|
|
.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%;
|
|
}
|
|
}
|
|
|
|
/* ── Search view ──────────────────────────────────────────────── */
|
|
|
|
.search-option-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 24px;
|
|
min-width: 24px;
|
|
padding: 0 6px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
border-radius: 4px;
|
|
border: 1px solid transparent;
|
|
background: transparent;
|
|
color: var(--app-subtle);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.search-option-btn:hover {
|
|
background: var(--app-hover);
|
|
color: var(--app-text-soft);
|
|
}
|
|
|
|
.search-option-active {
|
|
background: color-mix(in srgb, var(--app-accent) 15%, transparent);
|
|
color: var(--app-accent);
|
|
border-color: color-mix(in srgb, var(--app-accent) 30%, transparent);
|
|
}
|
|
|
|
.search-file-header {
|
|
color: var(--app-text-soft);
|
|
}
|
|
|
|
.search-match-item {
|
|
border-left: 2px solid transparent;
|
|
}
|
|
|
|
.search-match-item:hover {
|
|
border-left-color: var(--app-accent-muted);
|
|
}
|
|
|
|
.search-highlight {
|
|
background: color-mix(in srgb, var(--app-accent) 30%, transparent);
|
|
color: var(--app-accent);
|
|
border-radius: 2px;
|
|
padding: 0 1px;
|
|
}
|
|
|
|
.search-history-item {
|
|
color: var(--app-text-soft);
|
|
}
|
|
|
|
.search-ext-dropdown {
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
</style>
|