Fix: 重构部分逻辑,编辑器表格部分逻辑拆分,数据源改为适配器模式,增加键盘快捷键操作

This commit is contained in:
奇趣保罗 2026-01-29 12:01:09 +08:00
parent ff35f5b105
commit b51da785af
14 changed files with 1019 additions and 661 deletions

View File

@ -3,6 +3,7 @@ import {
MenubarContent, MenubarContent,
MenubarItem, MenubarItem,
MenubarMenu, MenubarMenu,
MenubarSeparator,
MenubarShortcut, MenubarShortcut,
MenubarTrigger, MenubarTrigger,
} from "@/components/ui/menubar"; } from "@/components/ui/menubar";
@ -10,6 +11,7 @@ import { ChevronDown } from "lucide-react";
export type CommonMenubarItem = export type CommonMenubarItem =
| "read" | "read"
| "read-all"
| "save" | "save"
| "import-with-directory" | "import-with-directory"
| "import-with-files" | "import-with-files"
@ -29,10 +31,14 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) {
</MenubarTrigger> </MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarItem onClick={() => onClickItem("read")} disabled={disabledItems?.read}> <MenubarItem onClick={() => onClickItem("read")} disabled={disabledItems?.read}>
<MenubarShortcut>R</MenubarShortcut> <MenubarShortcut>R</MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem onClick={() => onClickItem("read-all")} disabled={disabledItems?.read}>
<MenubarShortcut>R</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarItem onClick={() => onClickItem("save")} disabled={disabledItems?.save}> <MenubarItem onClick={() => onClickItem("save")} disabled={disabledItems?.save}>
<MenubarShortcut>S</MenubarShortcut> <MenubarShortcut>S</MenubarShortcut>
</MenubarItem> </MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
@ -54,7 +60,9 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) {
<ChevronDown className="size-4 opacity-30" /> <ChevronDown className="size-4 opacity-30" />
</MenubarTrigger> </MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarItem onClick={() => onClickItem("export")} disabled={disabledItems?.export}> JSON</MenubarItem> <MenubarItem onClick={() => onClickItem("export")} disabled={disabledItems?.export}>
JSON
</MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
</Menubar> </Menubar>

View File

@ -0,0 +1,388 @@
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { ArrowBigDownDash, ArrowBigUpDash, Brackets, CaseSensitive, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
import { toast } from "sonner";
import { useClipboard } from "@/hooks/use-clipboard";
import { useTableOptionState } from "./use-table-option-state";
import type { FlatEntry } from "@/lib/i18n-structure";
import type { TranslationInlineEditHandlers } from "@/hooks/biz/use-translation-inline-edit";
interface EditorTableProps {
entries: FlatEntry[];
languages: string[];
selected: Set<string>;
setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
inlineEdit: TranslationInlineEditHandlers;
onOpenAddEntry: (path: string, position: "above" | "below") => void;
onOpenAiTranslate: (path: string) => void;
onOpenBulkAiTranslate: () => void;
onOpenRenameEntry: (path: string) => void;
onMoveEntry: (path: string, offset: number) => Promise<void> | void;
onDeleteEntry: (path: string) => Promise<void> | void;
onDeleteSelected: (paths: string[]) => Promise<void> | void;
maxAiItems?: number;
}
export default function EditorTable({
entries,
languages,
selected,
setSelected,
inlineEdit,
onOpenAddEntry,
onOpenAiTranslate,
onOpenBulkAiTranslate,
onOpenRenameEntry,
onMoveEntry,
onDeleteEntry,
onDeleteSelected,
maxAiItems = 50,
}: EditorTableProps) {
const { copy } = useClipboard();
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set());
useEffect(() => {
setVisibleLangs(new Set(languages));
}, [languages]);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const scrollerRootRef = useRef<HTMLElement | Window | null>(null);
const {
query, setQuery,
caseSensitive, setCaseSensitive,
fullMatch, setFullMatch,
} = useTableOptionState();
const [moveCountUp, setMoveCountUp] = useState(1);
const [moveCountDown, setMoveCountDown] = useState(1);
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]);
const MAX_AI_ITEMS = maxAiItems;
const highlightRow = useCallback((index: number) => {
const tryFindAndAnimate = (attempt = 0) => {
const root = scrollerRootRef.current as HTMLElement | null;
if (!root) return;
const row = root.querySelector(`tr[data-item-index="${index}"]`) as HTMLTableRowElement | null;
if (row) {
row.animate(
[
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
],
{ duration: 1200, easing: "ease-in-out" }
);
return;
}
if (attempt < 10) {
setTimeout(() => tryFindAndAnimate(attempt + 1), 50);
}
};
requestAnimationFrame(() => tryFindAndAnimate(0));
}, []);
const scrollToQuery = useCallback((ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (!query) return;
const idx = entries.findIndex((e) => {
const hay = caseSensitive ? e.path : e.path.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
return fullMatch ? hay === needle : hay.includes(needle);
});
if (idx >= 0) {
virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) });
}
}, [caseSensitive, entries, fullMatch, highlightRow, query]);
const displayedLanguages = useMemo(() => languages.filter((l) => visibleLangs.has(l)), [languages, visibleLangs]);
const tableWidth = useMemo(() => (displayedLanguages.length * 200) + 36 + 60 + 300, [displayedLanguages.length]);
const virtuosoComponents = useMemo(() => ({
Table: ({ style, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} style={{ ...style, width: tableWidth }} />,
TableHead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead className="bg-muted" {...props} />,
TableRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr className="border-t" {...props} />,
TableBody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />,
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
}), [tableWidth]);
const headerContent = useCallback(() => (
<tr>
<th style={{ width: 36 }} className="sticky left-0 text-left px-3 py-2 bg-muted">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
const next = new Set<string>();
if (checked) {
for (const en of entries) next.add(en.path);
}
setSelected(next);
}}
/>
</th>
<th style={{ width: 300 }} className="text-left px-3 py-2"></th>
{displayedLanguages.map((lang) => (
<th key={lang} style={{ width: 200 }} className="text-left px-3 py-2">{lang}</th>
))}
<th style={{ width: 60 }} className="sticky right-0 text-left px-3 py-2 bg-muted"></th>
</tr>
), [allSelected, displayedLanguages, entries, setSelected]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
const handleCopy = () => {
copy(entry.path);
toast.success("复制成功");
};
return (
<>
<td className="sticky left-0 px-3 py-2 bg-white">
<Checkbox
checked={selected.has(entry.path)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(entry.path); else next.delete(entry.path);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono wrap-break-word">
<button type="button" className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
{entry.path}
</button>
</td>
{displayedLanguages.map((lang) => {
const isEditing = inlineEdit.isEditingCell(entry.path, lang);
const isSaving = inlineEdit.isSavingCell(entry.path, lang);
const displayValue = inlineEdit.getDisplayValue(entry.path, lang);
return (
<td key={`${entry.path}:${lang}`} className="px-3 py-2 text-foreground/90 align-top">
{isEditing ? (
<Textarea
ref={inlineEdit.inputRef}
value={inlineEdit.editingValue}
onChange={(e) => inlineEdit.setEditingValue(e.target.value)}
onBlur={() => { void inlineEdit.saveEdit(); }}
onKeyDown={inlineEdit.handleKeyDown}
disabled={isSaving}
className="leading-normal px-2 py-0"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent"
onClick={() => inlineEdit.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-destructive"></span>}
</button>
)}
</td>
);
})}
<td className="sticky right-0 px-3 py-2 align-top bg-white">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={() => onOpenAddEntry(entry.path, "below")}>
<ArrowBigDownDash />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpenAddEntry(entry.path, "above")}>
<ArrowBigUpDash />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenAiTranslate(entry.path)}>
<Languages />
AI
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenRenameEntry(entry.path)}>
<PencilLine />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={async () => {
await onMoveEntry(entry.path, -moveCountUp);
}}
>
<ArrowBigUpDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountUp}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountUp(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
await onMoveEntry(entry.path, moveCountDown);
}}
>
<ArrowBigDownDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountDown}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountDown(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={async () => {
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
await onDeleteEntry(entry.path);
}}
>
<Trash2 />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</>
);
}, [copy, displayedLanguages, inlineEdit, moveCountDown, moveCountUp, onDeleteEntry, onMoveEntry, onOpenAddEntry, onOpenAiTranslate, onOpenRenameEntry, selected, setSelected]);
return (
<div className="h-full flex flex-col">
<form className="mb-3 flex items-center gap-2" onSubmit={scrollToQuery}>
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
<Button
type="button"
variant={fullMatch ? "default" : "outline"}
onClick={() => setFullMatch((v) => !v)}
title="切换全量匹配/模糊匹配"
>
<Brackets />
{fullMatch ? "全量匹配" : "模糊匹配"}
</Button>
<Button
type="button"
variant={caseSensitive ? "default" : "outline"}
onClick={() => setCaseSensitive((v) => !v)}
title="切换大小写敏感"
>
<CaseSensitive />
{caseSensitive ? "区分大小写" : "忽略大小写"}
</Button>
<Button type="submit">
<LocateFixed />
</Button>
</form>
<div className="mb-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" title="过滤显示的语言列">
<Filter />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => setVisibleLangs(new Set(languages))}>
</DropdownMenuItem>
<DropdownMenuSeparator />
{languages.map((lang) => {
const checked = visibleLangs.has(lang);
return (
<DropdownMenuCheckboxItem
key={lang}
onSelect={(e) => e.preventDefault()}
checked={checked}
onCheckedChange={(v) => {
setVisibleLangs((prev) => {
const next = new Set(prev);
if (v) next.add(lang); else next.delete(lang);
return next;
});
}}
>
{lang}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
disabled={selected.size === 0 || selected.size > MAX_AI_ITEMS}
onClick={onOpenBulkAiTranslate}
title={selected.size > MAX_AI_ITEMS ? `最多支持 ${MAX_AI_ITEMS}` : "对所选条目进行 AI 翻译"}
>
<Languages />
AI {selected.size}
</Button>
{selected.size > MAX_AI_ITEMS && (
<span className="text-xs text-red-600"> {MAX_AI_ITEMS} </span>
)}
<Button
variant="outline-destructive"
disabled={selected.size === 0}
onClick={async () => {
if (!confirm(`确认删除所选 ${selected.size} 个条目?此操作会移除所有语言下的这些键`)) return;
await onDeleteSelected(Array.from(selected));
}}
title="删除所选条目"
>
<Trash2 />
</Button>
</div>
<div className="flex-1 border rounded-md">
<TableVirtuoso
ref={virtuosoRef}
data={entries}
fixedHeaderContent={headerContent}
itemContent={renderItemContent}
components={virtuosoComponents}
computeItemKey={(_index, entry) => entry.path}
scrollerRef={(el) => { scrollerRootRef.current = el; }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { useState } from "react";
export function useTableOptionState() {
const [query, setQuery] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [fullMatch, setFullMatch] = useState(false);
return {
query,
setQuery,
caseSensitive,
setCaseSensitive,
fullMatch,
setFullMatch,
};
}

View File

@ -175,9 +175,15 @@ export function ProjectSourcesWizard({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{" "}
<code>en/messages.json</code><code>zh-CN/messages.json</code>
</p> </p>
<pre className="text-xs rounded-md border bg-muted/40 p-3 text-muted-foreground"><code>{`en/
common.json
messages.json
zh-CN/
common.json
messages.json
`}</code></pre>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
type="button" type="button"

View File

@ -1,106 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useFileConnections } from "@/store/file-connection";
import { toast } from "sonner";
import { upsertLanguageTranslations } from "@/lib/db";
import { flattenValues } from "@/lib/i18n-structure";
import { RefreshCw } from "lucide-react";
type Props = {
projectId: string;
onSynced: () => void | Promise<void>;
disabled?: boolean;
};
async function ensureReadPermission(handle: FileSystemFileHandle): Promise<boolean> {
try {
// @ts-expect-error - queryPermission/requestPermission exist in supporting browsers
const q = await handle.queryPermission?.({ mode: "read" });
if (q === "granted") return true;
// @ts-expect-error - requestPermission exist in supporting browsers
const r = await handle.requestPermission?.({ mode: "read" });
return r === "granted";
} catch {
try {
// Fallback: try to read once; if it throws, we treat as no permission
const f = await handle.getFile();
// Touch the file object so TS doesn't complain about unused variable
if (!f) return false;
return true;
} catch {
return false;
}
}
}
export function SyncFromFilesButton({ projectId, onSynced, disabled }: Props) {
const [syncing, setSyncing] = useState(false);
const connSnap = useFileConnections(projectId);
const hasConnections = Object.keys(connSnap.connections).length > 0;
async function handleSync() {
if (!projectId) return;
if (!hasConnections) {
toast.info("没有已连接的语言");
return;
}
setSyncing(true);
try {
const entries = Object.entries(connSnap.connections);
const results = await Promise.allSettled(
entries.map(async ([lang, conn]) => {
const canRead = await ensureReadPermission(conn.handle);
if (!canRead) throw new Error(`${lang}: 无读取权限`);
const file = await conn.handle.getFile();
const text = await file.text();
let json: unknown;
try {
json = JSON.parse(text);
} catch {
throw new Error(`${lang}: JSON 解析失败`);
}
const values = flattenValues(json);
await upsertLanguageTranslations(projectId, lang, values);
return lang;
})
);
const failed: string[] = [];
let success = 0;
for (const r of results) {
if (r.status === "fulfilled") success += 1;
else failed.push((r.reason as Error)?.message || "未知语言");
}
if (failed.length === 0) {
toast.success(`同步完成(${success}`);
} else if (success === 0) {
toast.error(`全部失败(${failed.length}${failed.join("")}`);
} else {
toast.warning(`部分成功(成功 ${success},失败 ${failed.length}${failed.join("")}`);
}
} finally {
setSyncing(false);
await onSynced?.();
}
}
return (
<Button
variant="outline"
onClick={handleSync}
disabled={syncing || disabled || !hasConnections}
title={!hasConnections ? "暂无已连接的语言" : "读取已连接文件并导入翻译"}
>
<RefreshCw />
{syncing ? "读取中..." : "一键读取"}
</Button>
);
}

View File

@ -0,0 +1,87 @@
import { useEffect } from "react";
import type { CommonMenubarItem } from "@/components/biz/editor/common-menubar";
interface UseEditorKeyboardShortcutsOptions {
/**
*
*/
onClickItem: (item: CommonMenubarItem) => void;
/**
*
*/
disabledItems?: Partial<Record<CommonMenubarItem, boolean>>;
/**
* true
*/
enabled?: boolean;
}
/**
* Editor Hook
*
*
* - R / Ctrl+R: 读取单个文件
* - R / Ctrl+Shift+R: 自动读取并更新结构
* - S / Ctrl+S: 保存所有文件
* - L / Ctrl+L: 通过目录导入
* - I / Ctrl+I: 单文件导入
*/
export function useEditorKeyboardShortcuts({
onClickItem,
disabledItems,
enabled = true,
}: UseEditorKeyboardShortcutsOptions) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
// 检测是否按下 Cmd (Mac) 或 Ctrl (Windows/Linux)
const isMod = event.metaKey || event.ctrlKey;
if (!isMod) return;
// 定义快捷键映射
const shortcuts: Record<string, { item: CommonMenubarItem; shift: boolean }> = {
r: { item: "read", shift: false },
R: { item: "read-all", shift: true }, // Shift+R
s: { item: "save", shift: false },
l: { item: "import-with-directory", shift: false },
i: { item: "import-with-files", shift: false },
};
const key = event.key;
const shortcut = shortcuts[key];
if (!shortcut) return;
// 检查是否需要 Shift 键
if (shortcut.shift && !event.shiftKey) return;
if (!shortcut.shift && event.shiftKey && key.toLowerCase() === "r") {
// 如果是 Shift+R应该触发 read-all
const readAllShortcut = shortcuts["R"];
if (readAllShortcut && !disabledItems?.[readAllShortcut.item]) {
event.preventDefault();
onClickItem(readAllShortcut.item);
}
return;
}
// 检查该项是否被禁用
if (disabledItems?.[shortcut.item]) {
return;
}
// 阻止默认行为并执行操作
event.preventDefault();
onClickItem(shortcut.item);
};
// 注册全局键盘事件监听器
window.addEventListener("keydown", handleKeyDown);
// 清理函数
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClickItem, disabledItems, enabled]);
}

View File

@ -92,4 +92,6 @@ export function useTranslationInlineEdit(opts: {
} as const; } as const;
} }
export type TranslationInlineEditHandlers = ReturnType<typeof useTranslationInlineEdit>;

View File

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import type { LanguageSourceAdapter } from "@/lib/language-source/types";
import { createLanguageSourceAdapter } from "@/lib/language-source/manager";
import { useProjectSourcesStore } from "@/store/sources-store";
export function useLanguageAdapter(projectId?: string) {
const mode = useProjectSourcesStore((state) =>
projectId && state.projectId === projectId ? state.mode : null
);
const [adapter, setAdapter] = useState<LanguageSourceAdapter | null>();
useEffect(() => {
if (!projectId || !mode) {
setAdapter(null);
return;
}
const instance = createLanguageSourceAdapter(projectId, mode);
setAdapter(instance);
console.log("adapter", instance);
return () => {
instance.dispose?.();
};
}, [projectId, mode]);
return adapter;
}

View File

@ -0,0 +1,45 @@
import { listConnections, connectLanguageToFile, writeLanguageToConnectedFile, getConnection } from "@/store/file-connection";
import type { LanguageFileMeta, LanguagePayload, LanguageSourceAdapter } from "./types";
export class BrowserFileAdapter implements LanguageSourceAdapter {
public readonly mode = "browser" as const;
public readonly supportsBulkWrite = false;
private readonly projectId: string;
constructor(projectId: string) {
this.projectId = projectId;
}
async listLanguages(): Promise<LanguageFileMeta[]> {
return listConnections(this.projectId).map((conn) => ({
language: conn.language,
displayName: conn.name,
}));
}
async readLanguage(language: string): Promise<LanguagePayload> {
const existing = getConnection(this.projectId, language);
if (!existing) {
const picked = await connectLanguageToFile(this.projectId, language);
return {
language,
displayName: picked?.connection.name,
content: picked?.text ?? null,
};
}
const file = await existing.handle.getFile();
const text = await file.text();
return {
language,
displayName: existing.name,
content: text,
};
}
async writeLanguage(language: string, content: string): Promise<void> {
const ok = await writeLanguageToConnectedFile(this.projectId, language, content);
if (!ok) {
throw new Error(`语言 ${language} 未绑定文件或写入失败`);
}
}
}

View File

@ -0,0 +1,15 @@
import { isTauriEnv } from "@/lib/is-tauri";
import { BrowserFileAdapter } from "./browser-adapter";
import { TauriDirectoryAdapter } from "./tauri-adapter";
import type { LanguageSourceAdapter } from "./types";
export function createLanguageSourceAdapter(
projectId: string,
mode: "directory" | "flat"
): LanguageSourceAdapter {
if (isTauriEnv() && mode === "directory") {
return new TauriDirectoryAdapter(projectId);
}
return new BrowserFileAdapter(projectId);
}

View File

@ -0,0 +1,61 @@
import { loadNativeProjectSources, saveNativeLanguageFile, type LoadedProjectSources } from "@/lib/native-sources";
import type { LanguageFileMeta, LanguagePayload, LanguageSourceAdapter } from "./types";
export class TauriDirectoryAdapter implements LanguageSourceAdapter {
public readonly mode = "directory" as const;
public readonly supportsBulkWrite = true;
private readonly projectId: string;
private latest: LoadedProjectSources | null = null;
constructor(projectId: string) {
this.projectId = projectId;
}
async listLanguages(): Promise<LanguageFileMeta[]> {
const loaded = await this.ensureLoaded();
return loaded.languages.map((lang) => ({
language: lang.language,
displayName: lang.path?.split("/").pop() ?? lang.path,
path: lang.path,
exists: lang.exists,
modifiedMs: lang.modified_ms ?? undefined,
}));
}
async readLanguage(language: string): Promise<LanguagePayload> {
const loaded = await this.ensureLoaded();
const match = loaded.languages.find((lang) => lang.language === language);
if (!match) throw new Error(`未找到语言 ${language}`);
if (!match.content) throw new Error(`语言 ${language} 没有内容`);
return {
language: match.language,
displayName: match.path?.split("/").pop() ?? match.path,
path: match.path,
exists: match.exists,
content: match.content,
};
}
async writeLanguage(language: string, content: string): Promise<void> {
await saveNativeLanguageFile(this.projectId, language, content);
}
async readAll(): Promise<LanguagePayload[]> {
const loaded = await this.ensureLoaded(true);
return loaded.languages.map((lang) => ({
language: lang.language,
displayName: lang.path?.split("/").pop() ?? lang.path,
path: lang.path,
exists: lang.exists,
content: lang.content ?? null,
modifiedMs: lang.modified_ms ?? undefined,
}));
}
private async ensureLoaded(force = false): Promise<LoadedProjectSources> {
if (!this.latest || force) {
this.latest = await loadNativeProjectSources(this.projectId);
}
return this.latest;
}
}

View File

@ -0,0 +1,22 @@
export type LanguageFileMeta = {
language: string;
displayName?: string | null;
path?: string | null;
exists?: boolean;
modifiedMs?: number | null;
};
export type LanguagePayload = LanguageFileMeta & {
content?: string | null;
};
export interface LanguageSourceAdapter {
readonly mode: "browser" | "directory";
readonly supportsBulkWrite: boolean;
listLanguages(): Promise<LanguageFileMeta[]>;
readLanguage(language: string): Promise<LanguagePayload>;
writeLanguage(language: string, content: string): Promise<void>;
readAll?(): Promise<LanguagePayload[]>;
writeAll?(payloads: { language: string; content: string }[]): Promise<void>;
dispose?(): void;
}

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@ export type ProjectSourcesState = {
type ProjectSourcesActions = { type ProjectSourcesActions = {
setSources: (state: ProjectSourcesState) => void; setSources: (state: ProjectSourcesState) => void;
clear: () => void; clear: () => void;
getLanguagePath: (language: string) => string | undefined; getLanguageMeta: (language: string) => LanguageFileMeta | undefined;
}; };
const initialState: ProjectSourcesState = { const initialState: ProjectSourcesState = {
@ -38,8 +38,7 @@ export const useProjectSourcesStore = create<ProjectSourcesState & ProjectSource
...state, ...state,
})), })),
clear: () => set(() => initialState), clear: () => set(() => initialState),
getLanguagePath: (language) => { getLanguageMeta: (language) => {
const meta = get().languages.find((lang) => lang.language === language); return get().languages.find((lang) => lang.language === language);
return meta?.path;
}, },
})); }));