From 0da5d1ef6aa99d80e349517f79a039f2f16e9619 Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Wed, 5 Aug 2026 03:26:46 -0400 Subject: [PATCH 01/10] feat: add multilingual controls and safer file actions --- CHANGELOG.md | 20 + electron/force-delete.ts | 308 ++++++++++ electron/i18n.ts | 78 +++ electron/main.ts | 86 ++- electron/preload.ts | 4 + electron/store.ts | 24 + electron/types.ts | 44 ++ package-lock.json | 4 +- package.json | 3 +- scripts/smoke-force-delete.mjs | 58 ++ scripts/smoke-indexer.mjs | 14 + scripts/visual-smoke.mjs | 269 ++++++++- src/App.tsx | 104 +++- src/components/AiModelPicker.tsx | 10 +- src/components/AiProviderPicker.tsx | 26 +- src/components/ForceDeleteDialog.tsx | 237 ++++++++ src/components/LanguagePicker.tsx | 115 ++++ src/components/OwnershipContextMenu.tsx | 60 +- src/components/PathOpenFeedback.tsx | 8 +- src/components/PathPropertiesDialog.tsx | 136 +++-- src/components/SearchContextMenu.tsx | 102 ++-- src/components/Sidebar.tsx | 145 ++++- src/components/ThemedTooltip.tsx | 24 +- src/components/ui.tsx | 4 +- src/lib/api.ts | 33 +- src/lib/i18n.test.ts | 59 ++ src/lib/i18n.ts | 750 ++++++++++++++++++++++++ src/lib/releaseNotes.ts | 60 +- src/lib/search.ts | 32 +- src/main.tsx | 3 +- src/styles.css | 606 ++++++++++++++++++- src/types.ts | 46 ++ src/views/AnalyzeView.tsx | 206 ++++--- src/views/HistoryView.tsx | 90 +-- src/views/MigrateView.tsx | 122 ++-- src/views/OverviewView.tsx | 78 +-- src/views/OwnershipMapView.tsx | 149 ++--- src/views/QuickSearchWindow.tsx | 35 +- src/views/SearchView.tsx | 477 +++++++++------ src/views/SettingsView.tsx | 431 ++++++++------ src/views/UninstallRestoreView.tsx | 30 +- tests/force-delete.test.ts | 55 ++ 42 files changed, 4177 insertions(+), 968 deletions(-) create mode 100644 electron/force-delete.ts create mode 100644 electron/i18n.ts create mode 100644 scripts/smoke-force-delete.mjs create mode 100644 src/components/ForceDeleteDialog.tsx create mode 100644 src/components/LanguagePicker.tsx create mode 100644 src/lib/i18n.test.ts create mode 100644 src/lib/i18n.ts create mode 100644 tests/force-delete.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc7c48..5ef8ff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ 本项目采用 `0.0.x` 递增版本号。正式安装包、便携包和校验清单发布在 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 +## 0.0.7 + +### 搜索与文件操作 + +- 长路径悬浮提示改为显示完整路径,可在目录分隔位置自然换行,并自动保持在窗口边界内。 +- 搜索结果右键菜单新增受控强制删除:先枚举占用进程并要求确认,随后关闭相关进程树再删除目标;失败时保留明确诊断信息。 +- 目录名称搜索、分页懒加载、完整结果排序、实时新增/删除和内容搜索范围清理完成回归验证。 + +### 多语言与界面 + +- 新增不少于十种主流语言选择,补齐导航、搜索、设置、迁移、归属地图、更新说明和索引运行状态的动态切换。 +- 侧边栏支持低延迟拖动调整宽度与折叠,统一校准图标、标题、按钮、更新记录和搜索筛选区的对齐。 +- 五套主题补齐语言选择器、完整路径提示和强制删除确认窗口。 + +### 性能与验证 + +- 616 万级持久化名称索引的包含、全词和模糊查询继续保持亚秒响应。 +- 托盘静默时销毁渲染器并暂停全量工作,仅保留索引监听、快捷键与托盘能力。 +- 增加完整路径提示边界、占用进程强制删除、缓存重启、增量重放和动态文件变化回归测试。 + ## 0.0.6 ### 极速搜索 diff --git a/electron/force-delete.ts b/electron/force-delete.ts new file mode 100644 index 0000000..9b5fbc0 --- /dev/null +++ b/electron/force-delete.ts @@ -0,0 +1,308 @@ +import { execFile } from "node:child_process"; +import { lstat, rm } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { logger, serializeError } from "./logger"; +import { + isElevated, + isHighRiskApplicationPath, + isPathWithin, + normalizeWindowsPath, + protectedReason, + samePath +} from "./system"; +import type { + ForceDeletePreview, + ForceDeleteProcess, + ForceDeleteResult +} from "./types"; + +const execFileAsync = promisify(execFile); +const VERIFICATION_TTL_MS = 2 * 60_000; +const NEVER_TERMINATE = new Set([ + "system", + "registry", + "smss.exe", + "csrss.exe", + "wininit.exe", + "services.exe", + "lsass.exe", + "winlogon.exe", + "dwm.exe", + "explorer.exe", + "svchost.exe" +]); + +interface WindowsProcessRecord { + pid: number; + name: string; + executablePath?: string; + commandLine?: string; +} + +interface VerificationRecord { + path: string; + expiresAt: number; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function parseProcessList(raw: string): WindowsProcessRecord[] { + if (!raw.trim()) return []; + const parsed = JSON.parse(raw) as unknown; + const values = Array.isArray(parsed) ? parsed : [parsed]; + return values.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const item = value as Record; + const pid = Number(item.pid ?? item.ProcessId); + if (!Number.isInteger(pid) || pid <= 0) return []; + return [{ + pid, + name: optionalString(item.name ?? item.Name) ?? `PID ${pid}`, + executablePath: optionalString(item.executablePath ?? item.ExecutablePath), + commandLine: optionalString(item.commandLine ?? item.CommandLine) + }]; + }); +} + +function normalizeCommandLine(value: string): string { + return value.replaceAll("/", "\\").toLocaleLowerCase(); +} + +export function commandLineReferencesTarget( + commandLine: string, + targetPath: string +): boolean { + const haystack = normalizeCommandLine(commandLine); + const needle = normalizeWindowsPath(targetPath).toLocaleLowerCase(); + let offset = haystack.indexOf(needle); + while (offset >= 0) { + const before = offset === 0 ? "" : haystack[offset - 1]; + const after = haystack[offset + needle.length] ?? ""; + const beforeBoundary = before === "" || /[\s"'=]/u.test(before); + const afterBoundary = after === "" || /[\\\s"']/u.test(after); + if (beforeBoundary && afterBoundary) return true; + offset = haystack.indexOf(needle, offset + 1); + } + return false; +} + +export function matchProcessesForTarget( + processes: WindowsProcessRecord[], + targetPath: string, + targetIsDirectory: boolean, + excludedPids: ReadonlySet = new Set() +): ForceDeleteProcess[] { + return processes.flatMap((processRecord) => { + const executableMatch = processRecord.executablePath + ? targetIsDirectory + ? isPathWithin(processRecord.executablePath, targetPath) + : samePath(processRecord.executablePath, targetPath) + : false; + const commandLineMatch = processRecord.commandLine + ? commandLineReferencesTarget(processRecord.commandLine, targetPath) + : false; + if (!executableMatch && !commandLineMatch) return []; + const protectedProcess = + processRecord.pid <= 4 || + excludedPids.has(processRecord.pid) || + NEVER_TERMINATE.has(processRecord.name.toLocaleLowerCase()); + return [{ + pid: processRecord.pid, + name: processRecord.name, + executablePath: processRecord.executablePath, + matchReason: executableMatch ? "executable" : "command-line", + canTerminate: !protectedProcess + }]; + }); +} + +async function listWindowsProcesses(): Promise { + if (process.platform !== "win32") return []; + const script = [ + "$ErrorActionPreference='SilentlyContinue'", + "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new()", + "$items=@(Get-CimInstance Win32_Process | ForEach-Object {", + "[PSCustomObject]@{pid=[int]$_.ProcessId;name=[string]$_.Name;executablePath=[string]$_.ExecutablePath;commandLine=[string]$_.CommandLine}", + "})", + "ConvertTo-Json -Compress -InputObject $items" + ].join("; "); + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], + { windowsHide: true, timeout: 8_000, maxBuffer: 4 * 1024 * 1024 } + ); + return parseProcessList(stdout); +} + +async function terminateProcessTree(pid: number): Promise { + try { + process.kill(pid, 0); + } catch { + return true; + } + try { + await execFileAsync("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + timeout: 10_000, + maxBuffer: 512 * 1024 + }); + return true; + } catch { + try { + process.kill(pid, 0); + return false; + } catch { + return true; + } + } +} + +async function removePermanently(targetPath: string): Promise { + try { + await rm(targetPath, { + recursive: true, + force: true, + maxRetries: 6, + retryDelay: 300 + }); + return; + } catch (firstError) { + if (process.platform === "win32") { + await execFileAsync( + "attrib.exe", + ["-R", "-S", "-H", targetPath, "/S", "/D"], + { windowsHide: true, timeout: 15_000, maxBuffer: 512 * 1024 } + ).catch(() => undefined); + try { + await rm(targetPath, { + recursive: true, + force: true, + maxRetries: 8, + retryDelay: 400 + }); + return; + } catch (secondError) { + throw secondError; + } + } + throw firstError; + } +} + +export class ForceDeleteService { + private readonly verifications = new Map(); + + constructor( + private readonly options: { + applicationExecutable: string; + applicationDataRoot: string; + createVerificationId: () => string; + } + ) {} + + private validateTarget(targetPath: string): string { + const normalized = normalizeWindowsPath(targetPath); + const protection = protectedReason(normalized); + if (protection) throw new Error(`受保护路径不能强制删除:${protection}`); + if ( + isPathWithin(this.options.applicationExecutable, normalized) || + isPathWithin(this.options.applicationDataRoot, normalized) + ) { + throw new Error("不能强制删除 CDriveShiftAI 当前程序或数据所在目录"); + } + return normalized; + } + + private cleanExpiredVerifications(): void { + const now = Date.now(); + for (const [id, verification] of this.verifications) { + if (verification.expiresAt <= now) this.verifications.delete(id); + } + } + + async preview(targetPath: string): Promise { + this.cleanExpiredVerifications(); + const normalized = this.validateTarget(targetPath); + const stats = await lstat(normalized); + const processes = matchProcessesForTarget( + await listWindowsProcesses(), + normalized, + stats.isDirectory(), + new Set([process.pid, process.ppid]) + ); + const verificationId = this.options.createVerificationId(); + this.verifications.set(verificationId, { + path: normalized, + expiresAt: Date.now() + VERIFICATION_TTL_MS + }); + logger.info("force_delete.preview", { + targetPath: normalized, + isDirectory: stats.isDirectory(), + relatedProcesses: processes.length, + terminableProcesses: processes.filter((item) => item.canTerminate).length + }); + return { + verificationId, + path: normalized, + name: path.basename(normalized), + isDirectory: stats.isDirectory(), + isSymbolicLink: stats.isSymbolicLink(), + highRisk: isHighRiskApplicationPath(normalized), + elevated: await isElevated(), + processes + }; + } + + async execute(verificationId: string): Promise { + this.cleanExpiredVerifications(); + const verification = this.verifications.get(verificationId); + if (!verification) throw new Error("强制删除确认已过期,请重新预检"); + this.verifications.delete(verificationId); + const normalized = this.validateTarget(verification.path); + const stats = await lstat(normalized); + const processes = matchProcessesForTarget( + await listWindowsProcesses(), + normalized, + stats.isDirectory(), + new Set([process.pid, process.ppid]) + ); + const terminated: ForceDeleteProcess[] = []; + const failed: ForceDeleteProcess[] = []; + for (const processRecord of processes.filter((item) => item.canTerminate)) { + if (await terminateProcessTree(processRecord.pid)) terminated.push(processRecord); + else failed.push(processRecord); + } + if (failed.length > 0) { + logger.warn("force_delete.process_termination_failed", { + targetPath: normalized, + processes: failed.map(({ pid, name }) => ({ pid, name })) + }); + throw new Error( + `无法结束 ${failed.map((item) => `${item.name} (PID ${item.pid})`).join("、")};请以管理员身份运行后重试` + ); + } + try { + await removePermanently(normalized); + logger.info("force_delete.completed", { + targetPath: normalized, + terminatedProcesses: terminated.map(({ pid, name }) => ({ pid, name })) + }); + return { deleted: true, terminatedProcesses: terminated }; + } catch (error) { + logger.error("force_delete.failed", { + targetPath: normalized, + terminatedProcesses: terminated.map(({ pid, name }) => ({ pid, name })), + error: serializeError(error) + }); + const code = (error as NodeJS.ErrnoException).code; + throw new Error( + code === "EPERM" || code === "EACCES" || code === "EBUSY" + ? "目标仍被其他程序占用或当前权限不足;请关闭未列出的占用程序,或以管理员身份运行后重试" + : `强制删除失败:${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/electron/i18n.ts b/electron/i18n.ts new file mode 100644 index 0000000..fb1ea20 --- /dev/null +++ b/electron/i18n.ts @@ -0,0 +1,78 @@ +import { app } from "electron"; +import type { AppLanguage } from "./types"; + +type ResolvedLanguage = Exclude; + +const supported: readonly ResolvedLanguage[] = [ + "zh-CN", "zh-TW", "en-US", "ja-JP", "ko-KR", "es-ES", "fr-FR", "de-DE", + "pt-BR", "ru-RU", "ar-SA", "hi-IN", "id-ID", "it-IT", "tr-TR" +]; + +export function resolveNativeLanguage(language: AppLanguage): ResolvedLanguage { + if (language !== "system") return language; + const locale = app.getLocale().toLowerCase(); + if (locale.startsWith("zh-hant") || ["zh-tw", "zh-hk", "zh-mo"].includes(locale)) return "zh-TW"; + if (locale.startsWith("zh")) return "zh-CN"; + const exact = supported.find((item) => item.toLowerCase() === locale); + if (exact) return exact; + const prefix = locale.split("-")[0]; + return supported.find((item) => item.toLowerCase().startsWith(`${prefix}-`)) ?? "en-US"; +} + +const english = { + tagline: "Whole-disk search and safe AI migration", + quickWindow: "CDriveShiftAI Quick Search", + uninstallWindow: "CDriveShiftAI · Restore before uninstall", + open: "Open CDriveShiftAI", + quickSearch: "Standalone quick search", + quickFunctions: "Quick actions", + overview: "Space overview", + search: "Fast search", + ownership: "Disk ownership map", + analyze: "AI ownership analysis", + migrate: "Safe migration", + history: "Migration history", + aiService: "AI service", + enabled: "Enabled", + paused: "Paused", + notTested: "Connection test not completed", + configureAi: "Configure URL, key, and model…", + theme: "Interface theme", + settings: "Settings and shortcuts", + exit: "Exit", + unableAi: "Unable to switch AI service", + aurora: "Pixel · Pixel Lake", + matrix: "Tech · HUD data flow", + calm: "Crystal · Frosted glow", + ember: "Ember · Ember Hive", + ivory: "Ivory · Warm porcelain" +} as const; + +type NativeStrings = { [Key in keyof typeof english]: string }; +type NativeOverrides = Partial; + +const overrides: Record = { + "en-US": {}, + "zh-CN": { + tagline: "全盘 AI 智迁", quickWindow: "CDriveShiftAI 极速搜索", uninstallWindow: "CDriveShiftAI · 卸载前恢复", open: "打开 CDriveShiftAI", quickSearch: "独立极速搜索", quickFunctions: "快速功能", overview: "空间总览", search: "极速搜索", ownership: "磁盘归属地图", analyze: "AI 归属分析", migrate: "安全迁移", history: "迁移记录", aiService: "AI 服务", enabled: "已启用", paused: "已暂停", notTested: "尚未完成连接测试", configureAi: "配置 URL、Key 与模型…", theme: "界面主题", settings: "设置与快捷键", exit: "退出", unableAi: "无法切换 AI 服务", aurora: "方块 · 像素湖境", matrix: "科技 · HUD 数据流", calm: "晶境 · 玻璃流光", ember: "熔橙 · 熔芯蜂巢", ivory: "暖瓷 · 米白陶影" + }, + "zh-TW": { + tagline: "全碟 AI 智慧搬移", quickWindow: "CDriveShiftAI 快速搜尋", uninstallWindow: "CDriveShiftAI · 解除安裝前還原", open: "開啟 CDriveShiftAI", quickSearch: "獨立快速搜尋", quickFunctions: "快速功能", overview: "空間總覽", search: "快速搜尋", ownership: "磁碟歸屬地圖", analyze: "AI 歸屬分析", migrate: "安全搬移", history: "搬移記錄", aiService: "AI 服務", enabled: "已啟用", paused: "已暫停", notTested: "尚未完成連線測試", configureAi: "設定 URL、Key 與模型…", theme: "介面主題", settings: "設定與快速鍵", exit: "結束", unableAi: "無法切換 AI 服務" + }, + "ja-JP": { tagline: "全ドライブ検索と安全な AI 移行", quickWindow: "CDriveShiftAI クイック検索", open: "CDriveShiftAI を開く", quickSearch: "クイック検索", quickFunctions: "クイック操作", overview: "容量概要", search: "高速検索", ownership: "ディスク所有マップ", analyze: "AI 所有分析", migrate: "安全な移行", history: "移行履歴", aiService: "AI サービス", enabled: "有効", paused: "一時停止", notTested: "接続テスト未完了", configureAi: "URL・キー・モデルを設定…", theme: "テーマ", settings: "設定とショートカット", exit: "終了", unableAi: "AI サービスを切り替えられません" }, + "ko-KR": { tagline: "전체 디스크 검색과 안전한 AI 마이그레이션", quickWindow: "CDriveShiftAI 빠른 검색", open: "CDriveShiftAI 열기", quickSearch: "독립 빠른 검색", quickFunctions: "빠른 기능", overview: "공간 개요", search: "빠른 검색", ownership: "디스크 소유권 지도", analyze: "AI 소유권 분석", migrate: "안전한 이동", history: "이동 기록", aiService: "AI 서비스", enabled: "사용", paused: "일시 중지", notTested: "연결 테스트 미완료", configureAi: "URL, 키 및 모델 설정…", theme: "화면 테마", settings: "설정 및 바로가기", exit: "종료", unableAi: "AI 서비스를 전환할 수 없음" }, + "es-ES": { tagline: "Búsqueda en todos los discos y migración segura con IA", quickWindow: "Búsqueda rápida de CDriveShiftAI", open: "Abrir CDriveShiftAI", quickSearch: "Búsqueda rápida", quickFunctions: "Acciones rápidas", overview: "Resumen de espacio", search: "Búsqueda rápida", ownership: "Mapa de propiedad", analyze: "Análisis con IA", migrate: "Migración segura", history: "Historial", aiService: "Servicio de IA", enabled: "Activado", paused: "Pausado", notTested: "Prueba de conexión pendiente", configureAi: "Configurar URL, clave y modelo…", theme: "Tema de interfaz", settings: "Configuración y atajos", exit: "Salir", unableAi: "No se pudo cambiar el servicio de IA" }, + "fr-FR": { tagline: "Recherche sur tous les disques et migration IA sécurisée", quickWindow: "Recherche rapide CDriveShiftAI", open: "Ouvrir CDriveShiftAI", quickSearch: "Recherche rapide", quickFunctions: "Actions rapides", overview: "Vue de l’espace", search: "Recherche rapide", ownership: "Carte d’appartenance", analyze: "Analyse par IA", migrate: "Migration sécurisée", history: "Historique", aiService: "Service IA", enabled: "Activé", paused: "En pause", notTested: "Test de connexion non terminé", configureAi: "Configurer l’URL, la clé et le modèle…", theme: "Thème de l’interface", settings: "Paramètres et raccourcis", exit: "Quitter", unableAi: "Impossible de changer de service IA" }, + "de-DE": { tagline: "Laufwerksweite Suche und sichere KI-Migration", quickWindow: "CDriveShiftAI Schnellsuche", open: "CDriveShiftAI öffnen", quickSearch: "Schnellsuche", quickFunctions: "Schnellaktionen", overview: "Speicherübersicht", search: "Schnellsuche", ownership: "Zuordnungskarte", analyze: "KI-Zuordnungsanalyse", migrate: "Sichere Migration", history: "Migrationsverlauf", aiService: "KI-Dienst", enabled: "Aktiviert", paused: "Pausiert", notTested: "Verbindungstest nicht abgeschlossen", configureAi: "URL, Schlüssel und Modell einrichten…", theme: "Oberflächenthema", settings: "Einstellungen und Tastenkürzel", exit: "Beenden", unableAi: "KI-Dienst konnte nicht gewechselt werden" }, + "pt-BR": { tagline: "Busca em todos os discos e migração segura com IA", quickWindow: "Busca rápida do CDriveShiftAI", open: "Abrir CDriveShiftAI", quickSearch: "Busca rápida", quickFunctions: "Ações rápidas", overview: "Visão do espaço", search: "Busca rápida", ownership: "Mapa de propriedade", analyze: "Análise por IA", migrate: "Migração segura", history: "Histórico", aiService: "Serviço de IA", enabled: "Ativado", paused: "Pausado", notTested: "Teste de conexão pendente", configureAi: "Configurar URL, chave e modelo…", theme: "Tema da interface", settings: "Configurações e atalhos", exit: "Sair", unableAi: "Não foi possível trocar o serviço de IA" }, + "ru-RU": { tagline: "Поиск по всем дискам и безопасный перенос с ИИ", quickWindow: "Быстрый поиск CDriveShiftAI", open: "Открыть CDriveShiftAI", quickSearch: "Быстрый поиск", quickFunctions: "Быстрые действия", overview: "Обзор места", search: "Быстрый поиск", ownership: "Карта принадлежности", analyze: "Анализ с ИИ", migrate: "Безопасный перенос", history: "История", aiService: "Сервис ИИ", enabled: "Включён", paused: "Приостановлен", notTested: "Проверка подключения не завершена", configureAi: "Настроить URL, ключ и модель…", theme: "Тема интерфейса", settings: "Настройки и сочетания клавиш", exit: "Выход", unableAi: "Не удалось сменить сервис ИИ" }, + "ar-SA": { tagline: "بحث في جميع الأقراص ونقل آمن بالذكاء الاصطناعي", quickWindow: "بحث CDriveShiftAI السريع", open: "فتح CDriveShiftAI", quickSearch: "بحث سريع", quickFunctions: "إجراءات سريعة", overview: "نظرة على المساحة", search: "بحث سريع", ownership: "خريطة الملكية", analyze: "تحليل بالذكاء الاصطناعي", migrate: "نقل آمن", history: "سجل النقل", aiService: "خدمة الذكاء الاصطناعي", enabled: "مفعّل", paused: "متوقف مؤقتًا", notTested: "اختبار الاتصال غير مكتمل", configureAi: "إعداد الرابط والمفتاح والنموذج…", theme: "سمة الواجهة", settings: "الإعدادات والاختصارات", exit: "خروج", unableAi: "تعذر تبديل خدمة الذكاء الاصطناعي" }, + "hi-IN": { tagline: "सभी डिस्क में खोज और सुरक्षित AI माइग्रेशन", quickWindow: "CDriveShiftAI त्वरित खोज", open: "CDriveShiftAI खोलें", quickSearch: "त्वरित खोज", quickFunctions: "त्वरित क्रियाएँ", overview: "स्थान अवलोकन", search: "तेज़ खोज", ownership: "स्वामित्व मानचित्र", analyze: "AI विश्लेषण", migrate: "सुरक्षित माइग्रेशन", history: "इतिहास", aiService: "AI सेवा", enabled: "सक्रिय", paused: "रोका गया", notTested: "कनेक्शन परीक्षण अधूरा", configureAi: "URL, कुंजी और मॉडल कॉन्फ़िगर करें…", theme: "इंटरफ़ेस थीम", settings: "सेटिंग्स और शॉर्टकट", exit: "बाहर निकलें", unableAi: "AI सेवा बदली नहीं जा सकी" }, + "id-ID": { tagline: "Pencarian seluruh disk dan migrasi AI yang aman", quickWindow: "Pencarian cepat CDriveShiftAI", open: "Buka CDriveShiftAI", quickSearch: "Pencarian cepat", quickFunctions: "Tindakan cepat", overview: "Ringkasan ruang", search: "Pencarian cepat", ownership: "Peta kepemilikan", analyze: "Analisis AI", migrate: "Migrasi aman", history: "Riwayat", aiService: "Layanan AI", enabled: "Aktif", paused: "Dijeda", notTested: "Uji koneksi belum selesai", configureAi: "Atur URL, kunci, dan model…", theme: "Tema antarmuka", settings: "Pengaturan dan pintasan", exit: "Keluar", unableAi: "Tidak dapat mengganti layanan AI" }, + "it-IT": { tagline: "Ricerca su tutti i dischi e migrazione IA sicura", quickWindow: "Ricerca rapida CDriveShiftAI", open: "Apri CDriveShiftAI", quickSearch: "Ricerca rapida", quickFunctions: "Azioni rapide", overview: "Panoramica spazio", search: "Ricerca rapida", ownership: "Mappa proprietà", analyze: "Analisi IA", migrate: "Migrazione sicura", history: "Cronologia", aiService: "Servizio IA", enabled: "Attivo", paused: "In pausa", notTested: "Test di connessione non completato", configureAi: "Configura URL, chiave e modello…", theme: "Tema interfaccia", settings: "Impostazioni e scorciatoie", exit: "Esci", unableAi: "Impossibile cambiare servizio IA" }, + "tr-TR": { tagline: "Tüm disklerde arama ve güvenli yapay zekâ taşıması", quickWindow: "CDriveShiftAI hızlı arama", open: "CDriveShiftAI'ı aç", quickSearch: "Hızlı arama", quickFunctions: "Hızlı işlemler", overview: "Alan özeti", search: "Hızlı arama", ownership: "Sahiplik haritası", analyze: "Yapay zekâ analizi", migrate: "Güvenli taşıma", history: "Taşıma geçmişi", aiService: "Yapay zekâ hizmeti", enabled: "Etkin", paused: "Duraklatıldı", notTested: "Bağlantı testi tamamlanmadı", configureAi: "URL, anahtar ve model ayarla…", theme: "Arayüz teması", settings: "Ayarlar ve kısayollar", exit: "Çıkış", unableAi: "Yapay zekâ hizmeti değiştirilemedi" } +}; + +export function nativeStrings(language: AppLanguage): NativeStrings { + return { ...english, ...overrides[resolveNativeLanguage(language)] }; +} diff --git a/electron/main.ts b/electron/main.ts index 85c4011..f2ac8e7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -30,6 +30,8 @@ import { } from "./ai-client"; import { configureApplicationDataPaths } from "./data-root"; import { createDiagnosticReport } from "./diagnostics"; +import { ForceDeleteService } from "./force-delete"; +import { nativeStrings } from "./i18n"; import { configureLogger, getLogDirectory, @@ -104,6 +106,11 @@ let lastVisibleUpdateCheckAt = 0; const store = new AppStore(); let migrationService: MigrationService; const aiVerifications = new Map(); +const forceDeleteService = new ForceDeleteService({ + applicationExecutable: process.execPath, + applicationDataRoot, + createVerificationId: randomUUID +}); function syncSearchBackgroundMode(): void { const hasVisibleWindow = [mainWindow, quickSearchWindow, uninstallRestoreWindow].some( @@ -348,7 +355,9 @@ function showAsSoonAsRenderable(window: BrowserWindow, maximized = false): void } function createWindow(): BrowserWindow { - const effect = store.getSettings().effectMode; + const settings = store.getSettings(); + const effect = settings.effectMode; + const strings = nativeStrings(settings.language); const colors = effectColors[effect]; const restored = restoreWindowBounds( store.getUiLayout().mainWindowBounds, @@ -364,7 +373,7 @@ function createWindow(): BrowserWindow { show: false, backgroundColor: colors.background, icon: applicationIconPath(), - title: "CDriveShiftAI · 全盘 AI 智迁", + title: `CDriveShiftAI · ${strings.tagline}`, titleBarStyle: "hidden", titleBarOverlay: { color: "#00000000", @@ -423,12 +432,22 @@ function createWindow(): BrowserWindow { } function emitSettingsChanged(settings: AppSettings): void { + const strings = nativeStrings(settings.language); for (const window of [mainWindow, quickSearchWindow]) { if (window && !window.isDestroyed()) { window.webContents.send("settings:changed", settings); applyNativeEffect(settings.effectMode, window); } } + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setTitle(`CDriveShiftAI · ${strings.tagline}`); + } + if (quickSearchWindow && !quickSearchWindow.isDestroyed()) { + quickSearchWindow.setTitle(strings.quickWindow); + } + if (uninstallRestoreWindow && !uninstallRestoreWindow.isDestroyed()) { + uninstallRestoreWindow.setTitle(strings.uninstallWindow); + } } function emitUpdateState(state: AppUpdateInfo): void { @@ -522,6 +541,7 @@ function createQuickSearchWindow(): BrowserWindow { return quickSearchWindow; } const settings = store.getSettings(); + const strings = nativeStrings(settings.language); const colors = effectColors[settings.effectMode]; const restored = restoreWindowBounds( store.getUiLayout().quickSearchWindowBounds, @@ -537,7 +557,7 @@ function createQuickSearchWindow(): BrowserWindow { show: false, backgroundColor: colors.background, icon: applicationIconPath(), - title: "CDriveShiftAI 极速搜索", + title: strings.quickWindow, titleBarStyle: "hidden", titleBarOverlay: { color: "#00000000", @@ -571,6 +591,7 @@ function createQuickSearchWindow(): BrowserWindow { function createUninstallRestoreWindow(): BrowserWindow { const settings = store.getSettings(); + const strings = nativeStrings(settings.language); const colors = effectColors[settings.effectMode]; const window = new BrowserWindow({ width: 940, @@ -582,7 +603,7 @@ function createUninstallRestoreWindow(): BrowserWindow { maximizable: false, backgroundColor: colors.background, icon: applicationIconPath(), - title: "CDriveShiftAI · 卸载前恢复", + title: strings.uninstallWindow, titleBarStyle: "hidden", titleBarOverlay: { color: "#00000000", @@ -646,27 +667,28 @@ async function chooseTrayAiProvider(providerId: AiProviderId): Promise { } function createTray(): void { + const settings = store.getSettings(); + const strings = nativeStrings(settings.language); if (!tray) { const image = nativeImage.createFromPath(applicationIconPath()).resize({ width: 20, height: 20 }); tray = new Tray(image); - tray.setToolTip("CDriveShiftAI · 全盘 AI 智迁"); tray.on("double-click", () => showMainView("overview")); } + tray.setToolTip(`CDriveShiftAI · ${strings.tagline}`); - const settings = store.getSettings(); const providerLabel = trayAiProviders.find((item) => item.id === settings.ai.provider)?.label ?? settings.ai.provider; const viewItems: MenuItemConstructorOptions[] = [ - ["空间总览", "overview", "overview"], - ["极速搜索", "search", "search"], - ["磁盘归属地图", "ownership-map", "map"], - ["AI 归属分析", "analyze", "ai"], - ["安全迁移", "migrate", "move"], - ["迁移记录", "history", "history"] + [strings.overview, "overview", "overview"], + [strings.search, "search", "search"], + [strings.ownership, "ownership-map", "map"], + [strings.analyze, "analyze", "ai"], + [strings.migrate, "migrate", "move"], + [strings.history, "history", "history"] ].map(([label, view, icon]) => ({ label, icon: createTrayMenuIcon(icon as TrayIconKind), @@ -675,29 +697,29 @@ function createTray(): void { const template: MenuItemConstructorOptions[] = [ { - label: "打开 CDriveShiftAI", + label: strings.open, icon: createTrayMenuIcon("app"), click: () => showMainView("overview") }, { - label: "独立极速搜索", + label: strings.quickSearch, icon: createTrayMenuIcon("search", "#45aaba"), click: () => createQuickSearchWindow() }, { type: "separator" }, { - label: "快速功能", + label: strings.quickFunctions, icon: createTrayMenuIcon("quick", "#d9a441"), submenu: viewItems }, { - label: `AI 服务 · ${providerLabel}`, + label: `${strings.aiService} · ${providerLabel}`, icon: createTrayMenuIcon("ai", settings.ai.enabled ? "#32a879" : "#9a7b45"), submenu: [ { label: settings.ai.verifiedAt - ? `${settings.ai.enabled ? "已启用" : "已暂停"} · ${settings.ai.model || providerLabel}` - : "尚未完成连接测试", + ? `${settings.ai.enabled ? strings.enabled : strings.paused} · ${settings.ai.model || providerLabel}` + : strings.notTested, icon: createTrayMenuIcon("ai-status"), enabled: false }, @@ -716,7 +738,7 @@ function createTray(): void { click: () => { void chooseTrayAiProvider(provider.id).catch((error) => { void dialog.showErrorBox( - "无法切换 AI 服务", + strings.unableAi, error instanceof Error ? error.message : String(error) ); }); @@ -724,21 +746,21 @@ function createTray(): void { })), { type: "separator" }, { - label: "配置 URL、Key 与模型…", + label: strings.configureAi, icon: createTrayMenuIcon("ai-config"), click: () => showMainView("settings", { focus: "ai-settings" }) } ] }, { - label: "界面主题", + label: strings.theme, icon: createTrayMenuIcon("theme", "#a36fd1"), submenu: ([ - ["aurora", "方块 · 像素湖境"], - ["matrix", "科技 · HUD 数据流"], - ["calm", "晶境 · 玻璃流光"], - ["ember", "熔橙 · 熔芯蜂巢"], - ["ivory", "暖瓷 · 米白陶影"] + ["aurora", strings.aurora], + ["matrix", strings.matrix], + ["calm", strings.calm], + ["ember", strings.ember], + ["ivory", strings.ivory] ] as const).map(([effectMode, label]) => ({ label, type: "radio" as const, @@ -760,13 +782,13 @@ function createTray(): void { })) }, { - label: "设置与快捷键", + label: strings.settings, icon: createTrayMenuIcon("settings"), click: () => showMainView("settings") }, { type: "separator" }, { - label: "退出", + label: strings.exit, icon: createTrayMenuIcon("exit", "#bd5d5d"), click: () => app.quit() } @@ -1508,6 +1530,14 @@ function registerIpc(): void { return true; }); + ipcMain.handle("shell:force-delete-preview", (_event, targetPath: unknown) => + forceDeleteService.preview(assertString(targetPath, "路径")) + ); + + ipcMain.handle("shell:force-delete-execute", (_event, verificationId: unknown) => + forceDeleteService.execute(assertString(verificationId, "强制删除确认 ID", 128)) + ); + ipcMain.handle( "shell:search-context-menu", async (event, targetPath: unknown, isDirectory: unknown) => { diff --git a/electron/preload.ts b/electron/preload.ts index 358bcce..6e2d617 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -40,6 +40,10 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { getPathProperties: (targetPath: string) => ipcRenderer.invoke("shell:path-properties", targetPath), trashPath: (targetPath: string) => ipcRenderer.invoke("shell:trash", targetPath), + previewForceDelete: (targetPath: string) => + ipcRenderer.invoke("shell:force-delete-preview", targetPath), + executeForceDelete: (verificationId: string) => + ipcRenderer.invoke("shell:force-delete-execute", verificationId), directorySizes: (paths: string[]) => ipcRenderer.invoke("search:directory-sizes", paths), getSearchWorkspace: () => ipcRenderer.invoke("search:workspace-get"), saveSearchWorkspace: (state: unknown) => ipcRenderer.invoke("search:workspace-save", state), diff --git a/electron/store.ts b/electron/store.ts index 4a5b96f..d021736 100644 --- a/electron/store.ts +++ b/electron/store.ts @@ -20,6 +20,7 @@ import type { const defaults: StoreShape = { settings: { effectMode: "aurora", + language: "zh-CN", launchAtLogin: false, launchMinimized: false, minimizeToTray: true, @@ -293,6 +294,9 @@ function sanitizeUiLayout(value: unknown): UiLayoutState { if (typeof input.sidebarCollapsed === "boolean") { result.sidebarCollapsed = input.sidebarCollapsed; } + if (typeof input.sidebarWidth === "number" && Number.isFinite(input.sidebarWidth)) { + result.sidebarWidth = Math.max(190, Math.min(360, Math.round(input.sidebarWidth))); + } if ( input.searchResultColumnWidths && typeof input.searchResultColumnWidths === "object" @@ -497,6 +501,26 @@ function mergeSettings(input?: Partial): AppSettings { ) ? input!.effectMode! : defaults.settings.effectMode, + language: [ + "system", + "zh-CN", + "zh-TW", + "en-US", + "ja-JP", + "ko-KR", + "es-ES", + "fr-FR", + "de-DE", + "pt-BR", + "ru-RU", + "ar-SA", + "hi-IN", + "id-ID", + "it-IT", + "tr-TR" + ].includes(input?.language ?? "") + ? input!.language! + : defaults.settings.language, globalShortcut: typeof input?.globalShortcut === "string" ? input.globalShortcut.trim().slice(0, 128) diff --git a/electron/types.ts b/electron/types.ts index 48b064e..e83ac9c 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -134,6 +134,7 @@ export interface SearchBookmarkFolder { export interface UiLayoutState { sidebarCollapsed?: boolean; + sidebarWidth?: number; searchResultColumnWidths?: SearchResultColumnWidths; searchRenamePosition?: { x: number; @@ -156,6 +157,30 @@ export interface SearchContextActionResult { message?: string; } +export interface ForceDeleteProcess { + pid: number; + name: string; + executablePath?: string; + matchReason: "executable" | "command-line"; + canTerminate: boolean; +} + +export interface ForceDeletePreview { + verificationId: string; + path: string; + name: string; + isDirectory: boolean; + isSymbolicLink: boolean; + highRisk: boolean; + elevated: boolean; + processes: ForceDeleteProcess[]; +} + +export interface ForceDeleteResult { + deleted: boolean; + terminatedProcesses: ForceDeleteProcess[]; +} + export interface DirectorySizeResult { path: string; bytes: number; @@ -305,6 +330,7 @@ export interface OwnershipMapResult { export interface AppSettings { effectMode: "aurora" | "matrix" | "calm" | "ember" | "ivory"; + language: AppLanguage; launchAtLogin: boolean; launchMinimized: boolean; minimizeToTray: boolean; @@ -326,6 +352,24 @@ export interface AppSettings { }; } +export type AppLanguage = + | "system" + | "zh-CN" + | "zh-TW" + | "en-US" + | "ja-JP" + | "ko-KR" + | "es-ES" + | "fr-FR" + | "de-DE" + | "pt-BR" + | "ru-RU" + | "ar-SA" + | "hi-IN" + | "id-ID" + | "it-IT" + | "tr-TR"; + export type MouseShortcutButton = "disabled" | "back" | "forward" | "middle"; export interface MouseShortcutStatus { diff --git a/package-lock.json b/package-lock.json index 15f46fc..2914060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cdriveshiftai", - "version": "0.0.6", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cdriveshiftai", - "version": "0.0.6", + "version": "0.0.7", "license": "MIT", "dependencies": { "lucide-react": "^0.536.0", diff --git a/package.json b/package.json index fe126f3..9d96160 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cdriveshiftai", - "version": "0.0.6", + "version": "0.0.7", "private": true, "description": "CDriveShiftAI - AI-assisted Windows disk organizer and safe cross-drive directory migration tool", "main": "dist-electron/main.js", @@ -20,6 +20,7 @@ "dist:all": "npm run prepare:electron && npm run build:app && electron-builder --win nsis portable && node scripts/create-update-manifest.mjs", "test": "vitest run", "test:indexer": "node scripts/smoke-indexer.mjs", + "test:force-delete": "npm run build:main && node scripts/smoke-force-delete.mjs", "test:index-cache": "node scripts/smoke-index-cache-restart.mjs", "test:packaged-index-cache": "node scripts/smoke-packaged-index-cache.mjs", "test:background-cpu": "node scripts/smoke-background-cpu.mjs", diff --git a/scripts/smoke-force-delete.mjs b/scripts/smoke-force-delete.mjs new file mode 100644 index 0000000..c7e8335 --- /dev/null +++ b/scripts/smoke-force-delete.mjs @@ -0,0 +1,58 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const workspace = path.resolve(import.meta.dirname, ".."); +const require = createRequire(import.meta.url); +const { ForceDeleteService } = require(path.join(workspace, "dist-electron", "force-delete.js")); +const testRoot = path.join(workspace, ".cdriveshiftai-data", "test-temp"); +await mkdir(testRoot, { recursive: true }); +const fixture = await mkdtemp(path.join(testRoot, "force-delete-")); +const target = path.join(fixture, "occupied-target"); +await mkdir(target); +const commandFile = path.join(target, "hold-open.cmd"); +await writeFile(commandFile, "@echo off\r\nping -t 127.0.0.1 >nul\r\n", "utf8"); + +const child = spawn("cmd.exe", ["/d", "/c", commandFile], { + windowsHide: true, + detached: false, + stdio: "ignore" +}); + +try { + await new Promise((resolve) => setTimeout(resolve, 900)); + const service = new ForceDeleteService({ + applicationExecutable: process.execPath, + applicationDataRoot: path.join(workspace, ".cdriveshiftai-data"), + createVerificationId: () => "force-delete-smoke-verification" + }); + const preview = await service.preview(target); + const related = preview.processes.find((item) => item.pid === child.pid); + if (!related || !related.canTerminate) { + throw new Error(`Force-delete preview did not find the occupied fixture process: ${JSON.stringify(preview.processes)}`); + } + const result = await service.execute(preview.verificationId); + if (!result.deleted || !result.terminatedProcesses.some((item) => item.pid === child.pid)) { + throw new Error(`Force-delete execution did not terminate the related process: ${JSON.stringify(result)}`); + } + try { + await access(target); + throw new Error("Force-delete execution left the target directory on disk"); + } catch (error) { + if (error instanceof Error && error.message.includes("left the target")) throw error; + } + process.stdout.write(`${JSON.stringify({ + result: "ok", + relatedProcesses: preview.processes.length, + terminatedProcesses: result.terminatedProcesses.length, + targetRemoved: true + }, null, 2)}\n`); +} finally { + try { + child.kill("SIGKILL"); + } catch { + // The expected path already stopped the process tree. + } + await rm(fixture, { recursive: true, force: true }); +} diff --git a/scripts/smoke-indexer.mjs b/scripts/smoke-indexer.mjs index 28aed49..a9048ed 100644 --- a/scripts/smoke-indexer.mjs +++ b/scripts/smoke-indexer.mjs @@ -120,6 +120,20 @@ try { if (!nameResult.results?.some((result) => result.name === "Needle-file.txt")) { throw new Error("Name index smoke test did not find the fixture file"); } + const directoryResult = await request({ + op: "query", + query: "nested", + kind: "folder", + scope: fixture, + limit: 10 + }); + if ( + directoryResult.results?.length !== 1 || + directoryResult.results[0]?.name !== "nested" || + directoryResult.results[0]?.isDirectory !== true + ) { + throw new Error("Name index did not return a directly searchable directory result"); + } const firstPage = await request({ op: "query", query: "needle-page", diff --git a/scripts/visual-smoke.mjs b/scripts/visual-smoke.mjs index 5552a15..935f72b 100644 --- a/scripts/visual-smoke.mjs +++ b/scripts/visual-smoke.mjs @@ -168,6 +168,87 @@ try { await capture(`cdriveshiftai-${requestedView}.png`); if (requestedView === "settings") { + const iconAlignment = await evaluate(`(() => { + const update = document.querySelector(".brand-update-button")?.getBoundingClientRect(); + const collapse = document.querySelector(".sidebar-collapse-button")?.getBoundingClientRect(); + const bullet = document.querySelector(".update-release-items > span > i")?.getBoundingClientRect(); + const item = document.querySelector(".update-release-items > span"); + if (!update || !collapse || !bullet || !item) return null; + const itemBounds = item.getBoundingClientRect(); + const lineHeight = Number.parseFloat(getComputedStyle(item).lineHeight); + return { + brandDelta: Math.abs(update.top + update.height / 2 - (collapse.top + collapse.height / 2)), + releaseDelta: Math.abs(bullet.top + bullet.height / 2 - (itemBounds.top + lineHeight / 2)) + }; + })()`); + if (!iconAlignment || iconAlignment.brandDelta > 3 || iconAlignment.releaseDelta > 3) { + throw new Error(`Icon/text alignment regression: ${JSON.stringify(iconAlignment)}`); + } + const sidebarResize = await evaluate(`(() => { + const sidebar = document.querySelector(".sidebar"); + const handle = document.querySelector(".sidebar-resize-handle"); + if (!sidebar || !handle) return null; + const sidebarBounds = sidebar.getBoundingClientRect(); + const handleBounds = handle.getBoundingClientRect(); + return { + x: handleBounds.left + handleBounds.width / 2, + y: handleBounds.top + 120, + before: sidebarBounds.width + }; + })()`); + if (!sidebarResize) throw new Error("Sidebar resize handle was unavailable"); + await evaluate('document.querySelector(".sidebar-resize-handle")?.focus()'); + await send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "ArrowRight", + code: "ArrowRight", + windowsVirtualKeyCode: 39, + nativeVirtualKeyCode: 39 + }); + await send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "ArrowRight", + code: "ArrowRight", + windowsVirtualKeyCode: 39, + nativeVirtualKeyCode: 39 + }); + await waitFor( + `document.querySelector(".sidebar")?.getBoundingClientRect().width > ${JSON.stringify(sidebarResize.before)} + 8` + ); + await evaluate('document.querySelector(".sidebar-resize-handle")?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }))'); + await waitFor('Math.abs((document.querySelector(".sidebar")?.getBoundingClientRect().width ?? 0) - 238) < 2'); + const liveDrag = await evaluate(`(() => { + const handle = document.querySelector(".sidebar-resize-handle"); + if (!handle) return null; + const bounds = handle.getBoundingClientRect(); + return { x: bounds.left + bounds.width / 2, y: bounds.top + 150 }; + })()`); + if (!liveDrag) throw new Error("Sidebar drag target was unavailable"); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: liveDrag.x, + y: liveDrag.y, + button: "left", + clickCount: 1 + }); + await send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: liveDrag.x + 82, + y: liveDrag.y, + button: "left" + }); + const liveWidth = await evaluate('document.querySelector(".sidebar")?.getBoundingClientRect().width ?? 0'); + if (liveWidth < 305) throw new Error(`Sidebar did not track the pointer immediately: ${liveWidth}`); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: liveDrag.x + 82, + y: liveDrag.y, + button: "left", + clickCount: 1 + }); + await evaluate('document.querySelector(".sidebar-resize-handle")?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }))'); + await waitFor('Math.abs((document.querySelector(".sidebar")?.getBoundingClientRect().width ?? 0) - 238) < 2'); + const updateDot = await evaluate(`(() => { const button = document.querySelector(".brand-update-button"); if (!(button instanceof HTMLButtonElement)) return null; @@ -189,6 +270,112 @@ try { await waitFor('document.querySelector(".themed-tooltip") !== null'); await capture("cdriveshiftai-settings-version-tooltip.png"); await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 4, y: 4 }); + await evaluate('document.querySelectorAll(".settings-module-nav button")[1]?.click()'); + await waitFor('document.querySelector(".language-picker-trigger") !== null'); + await evaluate('document.querySelector(".language-picker-trigger")?.click()'); + await waitFor('document.querySelector(".language-picker-popover") !== null'); + await capture("cdriveshiftai-language-picker.png"); + await evaluate(`(() => { + const option = [...document.querySelectorAll(".language-picker-list button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "English"); + if (!(option instanceof HTMLButtonElement)) return false; + option.click(); + return true; + })()`); + await waitFor('[...document.querySelectorAll(".sidebar-bottom .nav-item span")].some((item) => item.textContent?.trim() === "Settings")'); + await evaluate(`(() => { + const button = [...document.querySelectorAll(".settings-module-nav button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "Updates & diagnostics"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".update-settings") !== null'); + const englishUpdateLeak = await evaluate(`(() => { + const text = document.querySelector(".update-settings")?.textContent ?? ""; + return [...new Set(text.match(/[\\u3400-\\u9fff]+/gu) ?? [])]; + })()`); + if (englishUpdateLeak.length > 0) { + throw new Error( + `Chinese text leaked into English update status or release notes: ${JSON.stringify(englishUpdateLeak)}` + ); + } + await evaluate(`(() => { + const button = [...document.querySelectorAll(".settings-module-nav button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "Index & shortcuts"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".settings-system-stack") !== null'); + const englishSystemLeak = await evaluate('/[\\u3400-\\u9fff]/u.test(document.querySelector(".settings-system-stack")?.textContent ?? "")'); + if (englishSystemLeak) throw new Error("Chinese text leaked into the English system settings module"); + await clickButton("Fast search"); + await waitFor('document.querySelector(".search-filter-workbench") !== null'); + const englishSearchLayout = await evaluate(`(() => { + const consoleText = document.querySelector(".search-console")?.textContent ?? ""; + const buttons = [...document.querySelectorAll(".match-mode-segments button")]; + const location = document.querySelector(".search-filter-location")?.getBoundingClientRect(); + const match = document.querySelector(".search-filter-match")?.getBoundingClientRect(); + const order = document.querySelector(".search-filter-order")?.getBoundingClientRect(); + return { + chineseLeak: /[\\u3400-\\u9fff]/u.test(consoleText), + clipped: buttons.some((button) => button.scrollWidth > button.clientWidth + 1), + labels: buttons.map((button) => button.textContent?.trim()), + sameRow: Boolean(location && match && order) && + Math.abs(location.top - match.top) < 2 && + Math.abs(location.top - order.top) < 2, + visible: Boolean(location) && location.top >= 56 && location.top < window.innerHeight + }; + })()`); + if ( + englishSearchLayout.chineseLeak || + englishSearchLayout.clipped || + !englishSearchLayout.sameRow || + !englishSearchLayout.visible + ) { + throw new Error(`English search localization/layout regression: ${JSON.stringify(englishSearchLayout)}`); + } + await wait(360); + await capture("cdriveshiftai-search-english.png"); + await clickButton("Disk ownership map"); + await waitFor('document.querySelector(".ownership-board") !== null'); + await wait(220); + const ownershipRuntimeLeak = await evaluate(`(() => { + const text = [...document.querySelectorAll(".ownership-owner")] + .map((item) => item.textContent ?? "") + .join(" "); + return [ + "Windows 应用安装体系", + "Windows 用户配置体系", + "面向全体用户的共享应用配置", + "卷影复制、还原点和文件系统服务数据", + "该卷的回收站系统数据", + "路径与 Windows 约定系统目录精确匹配" + ].some((value) => text.includes(value)); + })()`); + if (ownershipRuntimeLeak) { + throw new Error("Known ownership-map result text remained Chinese in English mode"); + } + await clickButton("Settings"); + await waitFor('document.querySelector(".settings-page") !== null'); + await evaluate(`(() => { + const button = [...document.querySelectorAll(".settings-module-nav button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "Appearance & language"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".language-picker-trigger") !== null'); + await evaluate('document.querySelector(".language-picker-trigger")?.click()'); + await evaluate(`(() => { + const option = [...document.querySelectorAll(".language-picker-list button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "简体中文"); + if (!(option instanceof HTMLButtonElement)) return false; + option.click(); + return true; + })()`); + await waitFor('[...document.querySelectorAll(".sidebar-bottom .nav-item span")].some((item) => item.textContent?.trim() === "设置")'); await evaluate(`(() => { const button = [...document.querySelectorAll(".settings-module-nav button")] .find((item) => item.querySelector("strong")?.textContent?.trim() === "索引与快捷操作"); @@ -202,6 +389,26 @@ try { } if (requestedView === "search") { + await evaluate(`(() => { + const button = [...document.querySelectorAll(".search-mode-switch button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "内容搜索"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".content-match-options") !== null'); + await clickButton("选择内容索引目录"); + await waitFor('document.querySelector(".scope-clear-button") !== null'); + await evaluate('document.querySelector(".scope-clear-button")?.click()'); + await waitFor('document.querySelector(".scope-clear-button") === null'); + await evaluate(`(() => { + const button = [...document.querySelectorAll(".search-mode-switch button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "名称搜索"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".search-filter-workbench") !== null'); await evaluate(`(() => { const input = document.querySelector(".search-input-wrap input"); if (!input) return false; @@ -373,6 +580,30 @@ try { y: pathRect.y }); await waitFor('document.querySelector(".themed-tooltip") !== null', true, 12); + const pathTooltipLayout = await evaluate(`(() => { + const path = document.querySelector(".result-path-cell"); + const tooltip = document.querySelector(".themed-tooltip--wrap"); + if (!path || !tooltip) return null; + const bounds = tooltip.getBoundingClientRect(); + return { + expected: path.textContent, + actual: tooltip.querySelector("span")?.textContent, + overflowX: tooltip.scrollWidth - tooltip.clientWidth, + right: bounds.right, + bottom: bounds.bottom, + viewportWidth: innerWidth, + viewportHeight: innerHeight + }; + })()`); + if (!pathTooltipLayout || pathTooltipLayout.actual !== pathTooltipLayout.expected) { + throw new Error(`Search result path tooltip did not expose the complete path: ${JSON.stringify(pathTooltipLayout)}`); + } + if (pathTooltipLayout.overflowX > 1) { + throw new Error(`Search result path tooltip still clips horizontally: ${JSON.stringify(pathTooltipLayout)}`); + } + if (pathTooltipLayout.right > pathTooltipLayout.viewportWidth + 1 || pathTooltipLayout.bottom > pathTooltipLayout.viewportHeight + 1) { + throw new Error(`Search result path tooltip escaped the viewport: ${JSON.stringify(pathTooltipLayout)}`); + } await capture("cdriveshiftai-search-result-tooltip-crystal.png"); await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 4, y: 4 }); await waitFor('document.querySelector(".themed-tooltip") === null', true, 12); @@ -403,6 +634,34 @@ try { await waitFor('document.querySelector(".search-context-menu") !== null'); await wait(160); await capture("cdriveshiftai-search-context-menu.png"); + const forceDeleteClicked = await evaluate(`(() => { + const button = [...document.querySelectorAll(".search-context-menu button")] + .find((item) => item.querySelector("span")?.textContent?.trim() === "强制永久删除…"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + if (!forceDeleteClicked) throw new Error("Force-delete action was unavailable"); + await waitFor('document.querySelector(".force-delete-dialog") !== null'); + await waitFor('document.querySelector(".force-delete-processes") !== null'); + await capture("cdriveshiftai-search-force-delete-preview.png"); + await evaluate('document.querySelector(".force-delete-dialog > header > button")?.click()'); + await waitFor('document.querySelector(".force-delete-dialog") === null'); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: resultRect.x, + y: resultRect.y, + button: "right", + clickCount: 1 + }); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: resultRect.x, + y: resultRect.y, + button: "right", + clickCount: 1 + }); + await waitFor('document.querySelector(".search-context-menu") !== null'); const propertiesClicked = await evaluate(`(() => { const button = [...document.querySelectorAll(".search-context-menu button")] .find((item) => item.querySelector("span")?.textContent?.trim() === "属性"); @@ -497,7 +756,7 @@ try { button: "left", clickCount: 1 }); - const propertiesMoved = await evaluate(`(() => { + await waitFor(`(() => { const bounds = document.querySelector(".path-properties-dialog")?.getBoundingClientRect(); return Boolean( bounds && @@ -505,7 +764,6 @@ try { bounds.top > ${JSON.stringify(propertiesHandle.beforeY)} + 20 ); })()`); - if (!propertiesMoved) throw new Error("Properties window did not move"); await send("Input.dispatchMouseEvent", { type: "mousePressed", x: 14, @@ -684,6 +942,10 @@ try { })()`); if (!contentModeClicked) throw new Error("Content search mode button was unavailable"); await waitFor('document.querySelector(".content-match-options") !== null'); + await clickButton("选择内容索引目录"); + await waitFor('document.querySelector(".scope-clear-button") !== null'); + await evaluate('document.querySelector(".scope-clear-button")?.click()'); + await waitFor('document.querySelector(".scope-clear-button") === null'); await evaluate(`(() => { const input = document.querySelector(".search-input-wrap input"); if (!(input instanceof HTMLInputElement)) return false; @@ -940,8 +1202,7 @@ try { await capture("cdriveshiftai-ai-provider-saved.png"); await evaluate('document.querySelector(".settings-page")?.scrollTo({ top: 0 })'); await evaluate(`(() => { - const button = [...document.querySelectorAll(".settings-module-nav button")] - .find((item) => item.querySelector("strong")?.textContent?.trim() === "界面外观"); + const button = document.querySelectorAll(".settings-module-nav button")[1]; if (!(button instanceof HTMLButtonElement)) return false; button.click(); return true; diff --git a/src/App.tsx b/src/App.tsx index 9795f08..2bdca83 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,22 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties +} from "react"; import { Bot, CloudOff, Database, Palette, ShieldCheck } from "lucide-react"; import { api } from "./lib/api"; import { effectBackgrounds, effectDefinitions, - effectLabels, isEffectMode, isLightEffect } from "./lib/effects"; +import { setAppLanguage, useI18n, type TranslationKey } from "./lib/i18n"; import type { AppSettings, AppUpdateInfo, @@ -47,8 +56,8 @@ const fallbackStatus: IndexerStatus = { state: "idle", entries: 0, progress: 0, - root: "本机所有磁盘", - message: "正在连接索引核心" + root: "All local drives", + message: "Connecting to the index service" }; function initialEffectMode(): EffectMode { @@ -67,6 +76,7 @@ function initialView(): ViewId { } export default function App() { + const { t, ui, runtimeText } = useI18n(); const [view, setView] = useState(initialView); const [overview, setOverview] = useState(); const [settings, setSettings] = useState(); @@ -74,6 +84,7 @@ export default function App() { const [history, setHistory] = useState([]); const [updateInfo, setUpdateInfo] = useState(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(238); const [settingsModule, setSettingsModule] = useState("update"); const [selectedPath, setSelectedPath] = useState(""); @@ -85,6 +96,14 @@ export default function App() { const [toasts, setToasts] = useState([]); const [startupEffect] = useState(initialEffectMode); const effectRequest = useRef(0); + const viewScrollRef = useRef(null); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + viewScrollRef.current?.scrollTo({ top: 0, left: 0, behavior: "auto" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [view]); const notify = useCallback((type: ToastItem["type"], message: string) => { const id = Date.now() + Math.floor(Math.random() * 1_000); @@ -112,30 +131,34 @@ export default function App() { setOverview(overviewResult.value); setIndexer(overviewResult.value.indexer); } else { - failures.push(`系统信息:${String(overviewResult.reason)}`); + failures.push(`${ui("系统信息", "System overview")}: ${String(overviewResult.reason)}`); } if (settingsResult.status === "fulfilled") { setSettings(settingsResult.value); + setAppLanguage(settingsResult.value.language); } else { - failures.push(`设置:${String(settingsResult.reason)}`); + failures.push(`${ui("设置", "Settings")}: ${String(settingsResult.reason)}`); } if (migrationsResult.status === "fulfilled") { setHistory(migrationsResult.value); } else { - failures.push(`迁移记录:${String(migrationsResult.reason)}`); + failures.push(`${ui("迁移记录", "Migration history")}: ${String(migrationsResult.reason)}`); } if (analysisResult.status === "fulfilled") { if (analysisResult.value) setSelectedPath(analysisResult.value.summary.path); } else { - failures.push(`分析记录:${String(analysisResult.reason)}`); + failures.push(`${ui("分析记录", "Analysis history")}: ${String(analysisResult.reason)}`); } if (layoutResult.status === "fulfilled") { setSidebarCollapsed(Boolean(layoutResult.value.sidebarCollapsed)); + if (layoutResult.value.sidebarWidth) { + setSidebarWidth(Math.max(190, Math.min(360, layoutResult.value.sidebarWidth))); + } } else { - failures.push(`界面布局:${String(layoutResult.reason)}`); + failures.push(`${ui("界面布局", "UI layout")}: ${String(layoutResult.reason)}`); } if (failures.length > 0) { - notify("error", `部分启动数据暂不可用:${failures.join(";")}`); + notify("error", `${ui("部分启动数据暂不可用", "Some startup data is temporarily unavailable")}: ${failures.join("; ")}`); } }); @@ -145,7 +168,7 @@ export default function App() { const next = items.filter((item) => item.id !== record.id); return [record, ...next]; }); - if (record.stage === "linked") notify("success", message); + if (record.stage === "linked") notify("success", runtimeText(message)); }); const offNavigation = api.onAppNavigation((event) => { if (event.path) { @@ -169,7 +192,10 @@ export default function App() { }, 180); } }); - const offSettings = api.onSettingsChanged(setSettings); + const offSettings = api.onSettingsChanged((updated) => { + setSettings(updated); + setAppLanguage(updated.language); + }); const offUpdate = api.onUpdateStatus(setUpdateInfo); void api.getUpdateState().then(setUpdateInfo).catch(() => undefined); return () => { @@ -179,7 +205,7 @@ export default function App() { offSettings(); offUpdate(); }; - }, [notify]); + }, [notify, runtimeText, ui]); const refreshUpdate = useCallback(async () => { const result = await api.checkForUpdates(true); @@ -193,11 +219,29 @@ export default function App() { void api .updateUiLayout({ sidebarCollapsed: next }) .catch((error) => - notify("error", `无法保存侧边栏状态:${error instanceof Error ? error.message : String(error)}`) + notify("error", `${ui("无法保存侧边栏状态", "Unable to save the sidebar state")}: ${error instanceof Error ? error.message : String(error)}`) ); return next; }); - }, [notify]); + }, [notify, ui]); + + const resizeSidebar = useCallback( + (width: number, collapsed: boolean, commit: boolean) => { + const normalizedWidth = Math.max(190, Math.min(360, Math.round(width))); + setSidebarWidth(normalizedWidth); + setSidebarCollapsed(collapsed); + if (!commit) return; + void api + .updateUiLayout({ sidebarWidth: normalizedWidth, sidebarCollapsed: collapsed }) + .catch((error) => + notify( + "error", + `${ui("无法保存侧边栏宽度", "Unable to save the sidebar width")}: ${error instanceof Error ? error.message : String(error)}` + ) + ); + }, + [notify, ui] + ); const effectMode = settings?.effectMode ?? startupEffect; useEffect(() => { @@ -207,7 +251,8 @@ export default function App() { document .querySelector('meta[name="theme-color"]') ?.setAttribute("content", effectBackgrounds[effectMode]); - }, [effectMode]); + document.title = `CDriveShiftAI · ${t("app.tagline")}`; + }, [effectMode, t]); const switchEffect = useCallback( async (mode: EffectMode) => { @@ -338,14 +383,19 @@ export default function App() { ]); return ( -
+
@@ -355,8 +405,8 @@ export default function App() { {indexer.state === "ready" - ? `自研索引 · ${indexer.mode.toUpperCase()}` - : "正在准备索引"} + ? t("top.indexReady", { mode: indexer.mode.toUpperCase() }) + : t("top.indexPreparing")}
@@ -365,15 +415,15 @@ export default function App() { className="privacy-pill" title={ settings?.ai.enabled - ? "AI 辅助已启用;发送范围由隐私设置控制。点击查看设置" - : "当前仅使用本地规则分析,不会向 AI 服务发送数据。点击配置 AI" + ? t("top.aiEnabledTip") + : t("top.aiLocalTip") } onClick={() => setView("settings")} > {settings?.ai.enabled ? : } - {settings?.ai.enabled ? "AI:辅助分析" : "AI:仅本地"} + {settings?.ai.enabled ? t("top.aiEnabled") : t("top.aiLocal")} -
+
{effectDefinitions.map(({ id: mode }) => ( ))}
-
+
- 正在打开功能页面 - 界面模块按需载入,索引和数据不会重新构建。 + {t("loading.page")} + {t("loading.detail")}
} > diff --git a/src/components/AiModelPicker.tsx b/src/components/AiModelPicker.tsx index 6716c22..c0b2a9b 100644 --- a/src/components/AiModelPicker.tsx +++ b/src/components/AiModelPicker.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronDown, Cpu } from "lucide-react"; import type { AiModelInfo } from "../types"; +import { useI18n } from "../lib/i18n"; interface AiModelPickerProps { models: AiModelInfo[]; @@ -19,6 +20,7 @@ export function AiModelPicker({ onChange, onCommit }: AiModelPickerProps) { + const { ui } = useI18n(); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const rootRef = useRef(null); @@ -73,7 +75,7 @@ export function AiModelPicker({ {open && models.length > 0 && ( -
+
- 服务端返回的对话模型 + {ui("服务端返回的对话模型", "Chat models returned by the provider")} {filtered.length} / {models.length}
@@ -114,7 +116,7 @@ export function AiModelPicker({ ))} {filtered.length === 0 && (
- 列表中没有匹配项;保留当前文本即可把它作为手动模型 ID 测试。 + {ui("列表中没有匹配项;保留当前文本即可把它作为手动模型 ID 测试。", "No matching item. Keep the current text to test it as a manual model ID.")}
)}
diff --git a/src/components/AiProviderPicker.tsx b/src/components/AiProviderPicker.tsx index 5395b5b..7829fc4 100644 --- a/src/components/AiProviderPicker.tsx +++ b/src/components/AiProviderPicker.tsx @@ -6,6 +6,7 @@ import { getAiProvider } from "../lib/aiProviders"; import type { AiProviderId } from "../types"; +import { useI18n } from "../lib/i18n"; interface AiProviderPickerProps { value: AiProviderId; @@ -18,6 +19,7 @@ export function AiProviderPicker({ disabled = false, onChange }: AiProviderPickerProps) { + const { ui } = useI18n(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const rootRef = useRef(null); @@ -68,20 +70,26 @@ export function AiProviderPicker({ > {selected.name} - {selected.group} + {ui(selected.group, { + "国际厂商": "International", + "国内厂商": "China", + "聚合平台": "Aggregators", + "本地模型": "Local models", + "自定义": "Custom" + }[selected.group])} {open && ( -
+
diff --git a/src/components/ForceDeleteDialog.tsx b/src/components/ForceDeleteDialog.tsx new file mode 100644 index 0000000..b1af68e --- /dev/null +++ b/src/components/ForceDeleteDialog.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { + AlertTriangle, + Cpu, + FileWarning, + FolderX, + LoaderCircle, + ShieldAlert, + Trash2, + X +} from "lucide-react"; +import { api } from "../lib/api"; +import { useI18n } from "../lib/i18n"; +import type { ForceDeletePreview } from "../types"; + +interface ForceDeleteDialogProps { + path: string; + onClose: () => void; + onDeleted: (path: string) => void; + notify: (type: "success" | "error", message: string) => void; +} + +export function ForceDeleteDialog({ + path, + onClose, + onDeleted, + notify +}: ForceDeleteDialogProps) { + const { ui } = useI18n(); + const [preview, setPreview] = useState(); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + void api + .previewForceDelete(path) + .then((value) => { + if (active) setPreview(value); + }) + .catch((reason) => { + if (active) { + const message = reason instanceof Error ? reason.message : String(reason); + setError( + message.includes("受保护路径") + ? ui("该路径受系统保护,不能强制删除", "This path is system-protected and cannot be force deleted") + : message.includes("CDriveShiftAI 当前程序") + ? ui("不能删除 CDriveShiftAI 当前程序或数据所在目录", "The active CDriveShiftAI program or data directory cannot be deleted") + : message + ); + } + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [path, ui]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !deleting) onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [deleting, onClose]); + + const execute = async () => { + if (!preview || !confirmed || deleting) return; + setDeleting(true); + setError(""); + try { + const result = await api.executeForceDelete(preview.verificationId); + if (!result.deleted) throw new Error(ui("强制删除未完成", "Force deletion did not complete")); + onDeleted(preview.path); + notify( + "success", + result.terminatedProcesses.length > 0 + ? ui( + `已结束 ${result.terminatedProcesses.length} 个相关进程并永久删除目标`, + `Stopped ${result.terminatedProcesses.length} related process(es) and permanently deleted the target` + ) + : ui("目标已永久删除", "Target permanently deleted") + ); + onClose(); + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason); + setError( + message.includes("确认已过期") + ? ui("删除预检已过期,请关闭窗口后重新操作", "The deletion preview expired. Close this dialog and try again") + : message.includes("管理员身份") || message.includes("权限不足") + ? ui("无法结束占用进程或权限不足,请以管理员身份运行后重试", "A related process could not be stopped or access was denied. Run as administrator and try again") + : message + ); + } finally { + setDeleting(false); + } + }; + + return createPortal( +
{ + if (event.target === event.currentTarget && !deleting) onClose(); + }} + > +
+
+
+
+ {ui("不可恢复操作", "IRREVERSIBLE ACTION")} +

{ui("强制永久删除", "Force permanent deletion")}

+
+ +
+ +
+
+ {preview?.isDirectory ? : } +
+ {preview?.name ?? path.split(/[\\/]/).pop() ?? path} + {preview?.path ?? path} +
+
+ + {loading && ( +
+ + {ui("正在检查占用进程和路径风险…", "Checking related processes and path risk…")} +
+ )} + + {error && !preview && ( +
+ + {error} +
+ )} + + {preview && ( + <> +
+ +
+ {ui("目标不会进入回收站", "The target will not go to the Recycle Bin")} + {ui( + "删除前会强制结束从该路径启动、或命令行直接引用该路径的相关进程,然后清除只读属性并重试删除。", + "Before deletion, processes launched from this path or directly referencing it on their command line will be forcibly stopped. Read-only attributes are then cleared before retrying deletion." + )} +
+
+ + {(preview.highRisk || !preview.elevated) && ( +
+ {preview.highRisk && {ui("应用安装目录:删除后程序可能无法运行", "Application directory: deleting it may break the app")}} + {!preview.elevated && {ui("当前不是管理员权限,部分进程可能无法结束", "Not running as administrator; some processes may not be stoppable")}} +
+ )} + +
+
+
{ui("相关进程", "Related processes")}
+ {preview.processes.length} +
+ {preview.processes.length === 0 ? ( +

{ui("未发现从目标路径启动或直接引用目标路径的进程。", "No process launched from or directly referencing the target path was found.")}

+ ) : ( +
+ {preview.processes.map((processItem) => ( +
+ +
+ {processItem.name} + {processItem.executablePath ?? ui("进程路径不可读", "Process path unavailable")} +
+ PID {processItem.pid} + {processItem.canTerminate ? ui("将结束", "Will stop") : ui("系统进程,不结束", "Protected")} +
+ ))} +
+ )} +
+ + + + )} + + {error && preview && ( +
+ + {error} +
+ )} +
+ +
+ {ui("普通删除仍可使用右键菜单中的“删除到回收站”", "Normal deletion remains available as “Move to Recycle Bin”")} +
+ + +
+
+
+
, + document.body + ); +} diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..5c6eaea --- /dev/null +++ b/src/components/LanguagePicker.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronDown, Globe2, Search } from "lucide-react"; +import { languageName, languageOptions, resolvedLanguage, useI18n } from "../lib/i18n"; +import type { AppLanguage } from "../types"; + +interface LanguagePickerProps { + value: AppLanguage; + disabled?: boolean; + onChange: (language: AppLanguage) => void; +} + +export function LanguagePicker({ value, disabled, onChange }: LanguagePickerProps) { + const { t, language: displayLanguage } = useI18n(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const rootRef = useRef(null); + const inputRef = useRef(null); + const selected = languageOptions.find((item) => item.id === value) ?? languageOptions[0]; + + const visible = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + if (!normalized) return languageOptions; + return languageOptions.filter((item) => + [item.nativeName, item.englishName, item.id, languageName(item.id, displayLanguage)] + .join(" ") + .toLocaleLowerCase() + .includes(normalized) + ); + }, [displayLanguage, query]); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.setTimeout(() => inputRef.current?.focus(), 0); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( +
+ + {open && ( +
+ +
+ {visible.map((item) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/OwnershipContextMenu.tsx b/src/components/OwnershipContextMenu.tsx index c1e9d2f..6c89b29 100644 --- a/src/components/OwnershipContextMenu.tsx +++ b/src/components/OwnershipContextMenu.tsx @@ -12,7 +12,7 @@ import { X } from "lucide-react"; import { api } from "../lib/api"; -import { formatDate } from "../lib/format"; +import { useI18n } from "../lib/i18n"; import type { OwnershipMapEntry } from "../types"; interface OwnershipContextMenuProps { @@ -27,16 +27,6 @@ interface OwnershipContextMenuProps { notify: (type: "success" | "error", message: string) => void; } -const categoryLabels: Record = { - application: "应用安装目录", - "application-data": "应用数据", - cache: "缓存/临时数据", - "user-data": "用户数据", - development: "开发数据", - system: "系统组件", - unknown: "待识别目录" -}; - export function OwnershipContextMenu({ entry, x, @@ -48,8 +38,18 @@ export function OwnershipContextMenu({ onDeleted, notify }: OwnershipContextMenuProps) { + const { ui, formatDate } = useI18n(); const [busy, setBusy] = useState(""); const menuRef = useRef(null); + const categoryLabels: Record = { + application: ui("应用安装目录", "Application installation"), + "application-data": ui("应用数据", "Application data"), + cache: ui("缓存/临时数据", "Cache / temporary data"), + "user-data": ui("用户数据", "User data"), + development: ui("开发数据", "Development data"), + system: ui("系统组件", "System component"), + unknown: ui("待识别目录", "Unidentified folder") + }; useEffect(() => { const close = (event: PointerEvent) => { @@ -89,7 +89,7 @@ export function OwnershipContextMenu({ ref={menuRef} style={{ left: x, top: y }} role="menu" - aria-label={`${entry.name} 的目录操作菜单`} + aria-label={ui(`${entry.name} 的目录操作菜单`, `Folder actions for ${entry.name}`)} onContextMenu={(event) => event.preventDefault()} >
@@ -105,17 +105,17 @@ export function OwnershipContextMenu({ {entry.lastModified ? ` · ${formatDate(entry.lastModified)}` : ""}
-
- {entry.risk === "blocked" ? "系统保护" : `风险 ${entry.risk}`} + {entry.risk === "blocked" ? ui("系统保护", "System protected") : ui(`风险 ${entry.risk}`, `Risk: ${entry.risk}`)} {entry.owner - ? `${Math.round(entry.owner.confidence * 100)}% 本地证据匹配` - : "尚未匹配到已安装应用"} + ? ui(`${Math.round(entry.owner.confidence * 100)}% 本地证据匹配`, `${Math.round(entry.owner.confidence * 100)}% local evidence match`) + : ui("尚未匹配到已安装应用", "No installed application matched")} {entry.explanation}
@@ -125,19 +125,19 @@ export function OwnershipContextMenu({ type="button" className="context-primary" disabled={Boolean(busy)} - onClick={() => void run("打开", () => api.openPath(entry.path))} + onClick={() => void run("open", () => api.openPath(entry.path))} > - 打开文件夹 + {ui("打开文件夹", "Open folder")} Enter @@ -151,7 +151,7 @@ export function OwnershipContextMenu({ }} > - 详细 AI 目录归属分析 + {ui("详细 AI 目录归属分析", "Detailed AI folder ownership analysis")}
@@ -200,17 +200,17 @@ export function OwnershipContextMenu({ className="danger" disabled={entry.risk === "blocked" || Boolean(busy)} onClick={() => - void run("删除", async () => { + void run("delete", async () => { const deleted = await api.trashPath(entry.path); if (deleted) { onDeleted(entry.path); - notify("success", "已移入回收站"); + notify("success", ui("已移入回收站", "Moved to the Recycle Bin")); } }) } > - {entry.risk === "blocked" ? "系统保护目录不可删除" : "删除到回收站"} + {entry.risk === "blocked" ? ui("系统保护目录不可删除", "System-protected folders cannot be deleted") : ui("删除到回收站", "Move to Recycle Bin")} Delete diff --git a/src/components/PathOpenFeedback.tsx b/src/components/PathOpenFeedback.tsx index e8d1d51..8e75bcd 100644 --- a/src/components/PathOpenFeedback.tsx +++ b/src/components/PathOpenFeedback.tsx @@ -6,6 +6,7 @@ import { type MouseEvent as ReactMouseEvent } from "react"; import { api } from "../lib/api"; +import { useI18n } from "../lib/i18n"; type OpenPhase = "opening" | "opened" | "error"; @@ -87,13 +88,14 @@ export function PathOpenFeedback({ path: string; feedback?: OpenFeedback; }) { + const { ui } = useI18n(); if (!feedback || feedback.path !== path) return null; const label = feedback.phase === "opening" - ? "正在交给 Windows 打开" + ? ui("正在交给 Windows 打开", "Opening with Windows") : feedback.phase === "opened" - ? "已打开" - : "打开失败"; + ? ui("已打开", "Opened") + : ui("打开失败", "Open failed"); return ( string) { if (value.isSymbolicLink) { - return value.isDirectory ? "目录符号链接" : "文件符号链接"; + return value.isDirectory + ? ui("目录符号链接", "Directory symbolic link") + : ui("文件符号链接", "File symbolic link"); } - if (value.isDirectory) return "文件夹"; - return value.extension ? `${value.extension.toUpperCase()} 文件` : "文件"; + if (value.isDirectory) return ui("文件夹", "Folder"); + return value.extension + ? ui(`${value.extension.toUpperCase()} 文件`, `${value.extension.toUpperCase()} file`) + : ui("文件", "File"); } function DateValue({ value }: { value?: string }) { - return {value ? formatDate(value) : "无可用记录"}; + const { ui, formatDate } = useI18n(); + return {value ? formatDate(value) : ui("无可用记录", "No record available")}; } export function PathPropertiesDialog({ @@ -67,6 +73,7 @@ export function PathPropertiesDialog({ onRenamed, notify }: PathPropertiesDialogProps) { + const { ui, formatNumber } = useI18n(); const [activePath, setActivePath] = useState(path); const [properties, setProperties] = useState(); const [error, setError] = useState(""); @@ -119,9 +126,12 @@ export function PathPropertiesDialog({ }, [onClose]); useEffect(() => { - const move = (event: MouseEvent) => { + const move = (event: MouseEvent | PointerEvent) => { const drag = dragRef.current; if (!drag) return; + if (event instanceof PointerEvent && drag.pointerId >= 0 && event.pointerId !== drag.pointerId) { + return; + } setPosition( clampPosition({ x: drag.originX + event.clientX - drag.startX, @@ -129,14 +139,30 @@ export function PathPropertiesDialog({ }) ); }; - const finish = () => { + const finish = (pointerId?: number) => { + if ( + pointerId != null && + dragRef.current && + dragRef.current.pointerId >= 0 && + dragRef.current.pointerId !== pointerId + ) { + return; + } dragRef.current = undefined; }; + const finishPointer = (event: PointerEvent) => finish(event.pointerId); + const finishMouse = () => finish(); + window.addEventListener("pointermove", move, true); + window.addEventListener("pointerup", finishPointer, true); + window.addEventListener("pointercancel", finishPointer, true); window.addEventListener("mousemove", move, true); - window.addEventListener("mouseup", finish, true); + window.addEventListener("mouseup", finishMouse, true); return () => { + window.removeEventListener("pointermove", move, true); + window.removeEventListener("pointerup", finishPointer, true); + window.removeEventListener("pointercancel", finishPointer, true); window.removeEventListener("mousemove", move, true); - window.removeEventListener("mouseup", finish, true); + window.removeEventListener("mouseup", finishMouse, true); }; }, []); @@ -191,7 +217,7 @@ export function PathPropertiesDialog({ const copyPath = async () => { try { await api.copyText(activePath); - notify("success", "完整路径已复制"); + notify("success", ui("完整路径已复制", "Full path copied")); } catch (reason) { notify("error", reason instanceof Error ? reason.message : String(reason)); } @@ -201,7 +227,7 @@ export function PathPropertiesDialog({ if (!properties || renameBusy) return; const requestedName = newName.trim(); if (!requestedName) { - notify("error", "名称不能为空"); + notify("error", ui("名称不能为空", "The name cannot be empty")); return; } if (requestedName === properties.name) { @@ -215,7 +241,7 @@ export function PathPropertiesDialog({ setActivePath(renamedPath); setRenaming(false); onRenamed?.(oldPath, renamedPath); - notify("success", "重命名完成"); + notify("success", ui("重命名完成", "Rename completed")); } catch (reason) { notify("error", reason instanceof Error ? reason.message : String(reason)); } finally { @@ -233,7 +259,7 @@ export function PathPropertiesDialog({ className="path-properties-dialog" role="dialog" aria-modal="true" - aria-label={`${properties?.name ?? activePath} 的属性`} + aria-label={ui(`${properties?.name ?? activePath} 的属性`, `Properties for ${properties?.name ?? activePath}`)} style={{ left: position.x, top: position.y }} onClick={(event) => event.stopPropagation()} > @@ -255,14 +281,14 @@ export function PathPropertiesDialog({ )}
- 项目属性 - {properties?.name ?? "正在读取…"} + {ui("项目属性", "Item properties")} + {properties?.name ?? ui("正在读取…", "Loading…")}
- 拖动窗口 + {ui("拖动窗口", "Drag window")}
- @@ -271,14 +297,14 @@ export function PathPropertiesDialog({ {error ? (
- 无法读取该项目 + {ui("无法读取该项目", "Unable to read this item")} {error}
) : !properties ? (
- 正在读取文件系统信息 - 大型目录的容量与项目数量统计可能需要一点时间。 + {ui("正在读取文件系统信息", "Reading file system information")} + {ui("大型目录的容量与项目数量统计可能需要一点时间。", "Size and item counts for large folders may take a moment.")}
) : ( <> @@ -287,13 +313,13 @@ export function PathPropertiesDialog({ {properties.path} {renaming && (
- +
)} @@ -322,57 +348,59 @@ export function PathPropertiesDialog({
- 大小 + {ui("大小", "Size")} {formatBytes(properties.size)} - {properties.scanComplete ? "统计完成" : "受时间或权限限制,为当前可读大小"} + {properties.scanComplete + ? ui("统计完成", "Scan complete") + : ui("受时间或权限限制,为当前可读大小", "Readable size only due to time or permission limits")}
- {properties.isDirectory ? "所含项目" : "占用空间"} + {properties.isDirectory ? ui("所含项目", "Items") : ui("占用空间", "Allocated size")} {properties.isDirectory - ? `${(properties.files ?? 0).toLocaleString()} 文件` + ? ui(`${(properties.files ?? 0).toLocaleString()} 文件`, `${formatNumber(properties.files ?? 0)} files`) : formatBytes(properties.allocatedBytes ?? properties.size)} {properties.isDirectory - ? `${(properties.directories ?? 0).toLocaleString()} 个子目录` - : "按文件系统分配块估算"} + ? ui(`${(properties.directories ?? 0).toLocaleString()} 个子目录`, `${formatNumber(properties.directories ?? 0)} subfolders`) + : ui("按文件系统分配块估算", "Estimated from file-system allocation")}
{properties.isDirectory ? : } - 项目类型 - {propertyType(properties)} - {properties.writable ? "可读写" : properties.readable ? "只读访问" : "访问受限"} + {ui("项目类型", "Item type")} + {propertyType(properties, ui)} + {properties.writable ? ui("可读写", "Read and write") : properties.readable ? ui("只读访问", "Read-only access") : ui("访问受限", "Access restricted")}
-

常规信息

+

{ui("常规信息", "General")}

-
名称
+
{ui("名称", "Name")}
{properties.name}
-
类型
-
{propertyType(properties)}
+
{ui("类型", "Type")}
+
{propertyType(properties, ui)}
-
位置
+
{ui("位置", "Location")}
{properties.parentPath}
-
扩展名
-
{properties.extension ? `.${properties.extension}` : "无"}
+
{ui("扩展名", "Extension")}
+
{properties.extension ? `.${properties.extension}` : ui("无", "None")}
{properties.linkTarget && (
-
链接目标
+
{ui("链接目标", "Link target")}
{properties.linkTarget}
)} @@ -380,26 +408,26 @@ export function PathPropertiesDialog({
-

时间与访问

+

{ui("时间与访问", "Time and access")}

-
创建时间
+
{ui("创建时间", "Created")}
-
修改时间
+
{ui("修改时间", "Modified")}
-
访问时间
+
{ui("访问时间", "Accessed")}
-
当前权限
+
{ui("当前权限", "Current access")}
- {properties.readable && 读取} - {properties.writable && 写入} - {!properties.readable && !properties.writable && "无访问权限"} + {properties.readable && {ui("读取", "Read")}} + {properties.writable && {ui("写入", "Write")}} + {!properties.readable && !properties.writable && ui("无访问权限", "No access")}
@@ -410,7 +438,7 @@ export function PathPropertiesDialog({
- 信息直接读取自当前文件系统 + {ui("信息直接读取自当前文件系统", "Information read directly from the current file system")}
- +
diff --git a/src/components/SearchContextMenu.tsx b/src/components/SearchContextMenu.tsx index c3a8445..2a84c83 100644 --- a/src/components/SearchContextMenu.tsx +++ b/src/components/SearchContextMenu.tsx @@ -20,12 +20,14 @@ import { Pencil, Play, Search, + ShieldAlert, Sparkles, Trash2, X } from "lucide-react"; import { api } from "../lib/api"; -import { formatBytes, formatDate } from "../lib/format"; +import { useI18n } from "../lib/i18n"; +import { formatBytes } from "../lib/format"; import type { SearchResult } from "../types"; interface SearchContextMenuProps { @@ -40,6 +42,7 @@ interface SearchContextMenuProps { onFindSameName: (name: string) => void; onFilterExtension: (extension: string) => void; onProperties: (path: string) => void; + onForceDelete: (path: string) => void; onDeleted: (path: string) => void; onRenamed: (oldPath: string, newPath: string) => void; notify: (type: "success" | "error", message: string) => void; @@ -80,10 +83,12 @@ export function SearchContextMenu({ onFindSameName, onFilterExtension, onProperties, + onForceDelete, onDeleted, onRenamed, notify }: SearchContextMenuProps) { + const { ui, formatDate } = useI18n(); const [renaming, setRenaming] = useState(initialRename); const [newName, setNewName] = useState(item.name); const [busy, setBusy] = useState(""); @@ -157,7 +162,8 @@ export function SearchContextMenu({ .catch((error) => notify( "error", - `无法保存重命名窗口位置:${error instanceof Error ? error.message : String(error)}` + ui("无法保存重命名窗口位置:", "Could not save the rename window position: ") + + (error instanceof Error ? error.message : String(error)) ) ); }; @@ -253,7 +259,11 @@ export function SearchContextMenu({ void api .updateUiLayout({ searchRenamePosition: finalPosition }) .catch((error) => - notify("error", `无法保存重命名窗口位置:${error instanceof Error ? error.message : String(error)}`) + notify( + "error", + ui("无法保存重命名窗口位置:", "Could not save the rename window position: ") + + (error instanceof Error ? error.message : String(error)) + ) ); }; @@ -276,13 +286,13 @@ export function SearchContextMenu({ }; const copyTo = async () => { - const destination = await api.chooseDirectory("选择复制目标目录"); + const destination = await api.chooseDirectory(ui("选择复制目标目录", "Choose destination folder")); if (!destination) return; await run( - "复制到", + "copy-to", async () => { const output = await api.copyPathToDirectory(item.path, destination); - notify("success", `已复制到 ${output}`); + notify("success", ui(`已复制到 ${output}`, `Copied to ${output}`)); }, undefined ); @@ -295,12 +305,12 @@ export function SearchContextMenu({ return; } await run( - "重命名", + "rename", async () => { const output = await api.renamePath(item.path, value); onRenamed(item.path, output); }, - "重命名完成" + ui("重命名完成", "Rename completed") ); }; @@ -310,7 +320,7 @@ export function SearchContextMenu({ ref={menuRef} style={{ left: position.x, top: position.y }} role="menu" - aria-label={`${item.name} 的操作菜单`} + aria-label={ui(`${item.name} 的操作菜单`, `Actions for ${item.name}`)} onContextMenu={(event) => event.preventDefault()} >
{item.path} {renaming - ? "按住这里拖动 · 松开后自动记住位置" + ? ui("按住这里拖动 · 松开后自动记住位置", "Drag here · the position is saved when released") : ( <> - {item.isDirectory ? "文件夹" : extension ? extension.toUpperCase() : "文件"} + {item.isDirectory ? ui("文件夹", "Folder") : extension ? extension.toUpperCase() : ui("文件", "File")} {" · "} - {!item.isDirectory || item.size > 0 ? formatBytes(item.size) : "大小计算中"} + {!item.isDirectory || item.size > 0 ? formatBytes(item.size) : ui("大小计算中", "Calculating size")} {item.modifiedAt ? ` · ${formatDate(item.modifiedAt)}` : ""} )} -
@@ -349,7 +359,7 @@ export function SearchContextMenu({
@@ -376,28 +386,28 @@ export function SearchContextMenu({ type="button" className="context-primary" onClick={() => - void run("打开", () => api.openPath(item.path)) + void run("open", () => api.openPath(item.path)) } > - {item.isDirectory ? "打开文件夹" : "使用系统默认方式打开"} + {item.isDirectory ? ui("打开文件夹", "Open folder") : ui("使用系统默认方式打开", "Open with the default app")} Enter {!item.isDirectory && ( )} @@ -412,7 +422,7 @@ export function SearchContextMenu({ }} > - 在此文件夹内搜索名称 + {ui("在此文件夹内搜索名称", "Search names in this folder")} )} @@ -451,34 +461,34 @@ export function SearchContextMenu({ @@ -491,7 +501,7 @@ export function SearchContextMenu({ }} > - 查找同名项目 + {ui("查找同名项目", "Find items with the same name")} {!item.isDirectory && extension && ( )} @@ -529,12 +539,12 @@ export function SearchContextMenu({ className="danger" onClick={() => void run( - "删除", + "delete", async () => { const deleted = await api.trashPath(item.path); if (deleted) { onDeleted(item.path); - notify("success", "已移入回收站"); + notify("success", ui("已移入回收站", "Moved to the Recycle Bin")); } }, undefined @@ -542,9 +552,21 @@ export function SearchContextMenu({ } > - 删除到回收站 + {ui("删除到回收站", "Move to Recycle Bin")} Delete + )} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index e947ae7..55d9c4a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,16 +12,23 @@ import { Sparkles } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import { + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent +} from "react"; import type { AppUpdateInfo, IndexerStatus, ViewId } from "../types"; +import { useI18n, type TranslationKey } from "../lib/i18n"; import { ThemedTooltip } from "./ThemedTooltip"; -const items: Array<{ id: ViewId; label: string; icon: LucideIcon }> = [ - { id: "overview", label: "空间总览", icon: LayoutDashboard }, - { id: "search", label: "极速搜索", icon: Search }, - { id: "ownership-map", label: "磁盘归属地图", icon: Map }, - { id: "analyze", label: "AI 归属分析", icon: ScanSearch }, - { id: "migrate", label: "安全迁移", icon: ArrowRightLeft }, - { id: "history", label: "迁移记录", icon: History } +const items: Array<{ id: ViewId; label: TranslationKey; icon: LucideIcon }> = [ + { id: "overview", label: "nav.overview", icon: LayoutDashboard }, + { id: "search", label: "nav.search", icon: Search }, + { id: "ownership-map", label: "nav.ownership", icon: Map }, + { id: "analyze", label: "nav.analyze", icon: ScanSearch }, + { id: "migrate", label: "nav.migrate", icon: ArrowRightLeft }, + { id: "history", label: "nav.history", icon: History } ]; interface SidebarProps { @@ -29,7 +36,9 @@ interface SidebarProps { onChange: (view: ViewId) => void; indexer: IndexerStatus; collapsed: boolean; + width: number; onToggle: () => void; + onResize: (width: number, collapsed: boolean, commit: boolean) => void; updateInfo?: AppUpdateInfo; } @@ -38,9 +47,20 @@ export function Sidebar({ onChange, indexer, collapsed, + width, onToggle, + onResize, updateInfo }: SidebarProps) { + const { t, formatNumber, direction } = useI18n(); + const [resizing, setResizing] = useState(false); + const resizeState = useRef<{ + pointerId: number; + startX: number; + startWidth: number; + width: number; + collapsed: boolean; + } | undefined>(undefined); const ready = indexer.state === "ready"; const updateStatus = updateInfo?.updateAvailable ? "available" @@ -48,16 +68,76 @@ export function Sidebar({ const updateTooltip = updateStatus === "available" ? updateInfo?.status === "unavailable" - ? `已检测到新版本 v${updateInfo?.latestVersion ?? "未知"};本次联网复查失败,但不会清除已确认的更新。点击进入设置查看。` - : `发现新版本 v${updateInfo?.latestVersion ?? "未知"};当前为 v${updateInfo?.currentVersion ?? "未知"}。点击进入设置更新。` + ? t("sidebar.updateStale", { latest: updateInfo?.latestVersion ?? t("general.unknown") }) + : t("sidebar.updateAvailable", { + latest: updateInfo?.latestVersion ?? t("general.unknown"), + current: updateInfo?.currentVersion ?? t("general.unknown") + }) : updateStatus === "unavailable" - ? `暂时无法检查版本:${updateInfo?.message ?? "请稍后重试"}。点击进入设置查看。` + ? t("sidebar.updateUnavailable", { message: updateInfo?.message ?? "—" }) : updateStatus === "current" - ? `当前版本 v${updateInfo?.currentVersion ?? "未知"},已是最新版本。` - : "正在检查 GitHub Release 版本…"; + ? t("sidebar.updateCurrent", { current: updateInfo?.currentVersion ?? t("general.unknown") }) + : t("sidebar.updateChecking"); + + const beginResize = (event: ReactPointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setResizing(true); + resizeState.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: collapsed ? 72 : width, + width, + collapsed + }; + }; + + const continueResize = (event: ReactPointerEvent) => { + const state = resizeState.current; + if (!state || state.pointerId !== event.pointerId) return; + const physicalDelta = event.clientX - state.startX; + const requested = state.startWidth + (direction === "rtl" ? -physicalDelta : physicalDelta); + const nextCollapsed = requested < 145; + const nextWidth = Math.max(190, Math.min(360, requested)); + const shell = event.currentTarget.closest(".app-shell"); + shell?.style.setProperty("--sidebar-width", `${Math.round(nextWidth)}px`); + if (nextCollapsed !== state.collapsed) { + onResize(nextWidth, nextCollapsed, false); + } + state.width = nextWidth; + state.collapsed = nextCollapsed; + }; + + const finishResize = (event: ReactPointerEvent) => { + const state = resizeState.current; + if (!state || state.pointerId !== event.pointerId) return; + resizeState.current = undefined; + setResizing(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + onResize(state.width, state.collapsed, true); + }; + + const resizeWithKeyboard = (event: ReactKeyboardEvent) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "Home") { + onResize(width, true, true); + return; + } + if (event.key === "End") { + onResize(360, false, true); + return; + } + const physicalStep = event.key === "ArrowRight" ? 12 : -12; + const step = direction === "rtl" ? -physicalStep : physicalStep; + const base = collapsed ? 132 : width; + onResize(base + step, false, true); + }; return ( -