diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2a3bcf07..07e20d7d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
+ - v2
pull_request:
branches:
- main
+ - v2
workflow_dispatch:
permissions:
@@ -108,7 +110,7 @@ jobs:
- name: Test
env:
MARKRA_APP_TEST_SHARD: ${{ matrix.shard }}
- run: pnpm --filter "${{ matrix.package }}" test -- ${{ matrix.args }}
+ run: pnpm --filter "${{ matrix.package }}" test ${{ matrix.args }}
frontend-test-typecheck:
name: Frontend Test Typecheck
diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx
index ce28eeca..9f7a47b2 100644
--- a/packages/app/src/App.test.tsx
+++ b/packages/app/src/App.test.tsx
@@ -1290,7 +1290,7 @@ describe("Markra workspace", () => {
expect(screen.getByRole("complementary", { name: "Markdown file tree" })).toHaveAttribute("aria-hidden", "false");
expect(screen.getByRole("tab", { name: /browser\.md/ })).toBeInTheDocument();
expect(container.querySelector(".native-titlebar")).toHaveStyle({
- gridTemplateColumns: "minmax(0,1fr) 164px",
+ gridTemplateColumns: "minmax(0,1fr) 196px",
left: "289px"
});
expect(container.querySelector(".windows-app-chrome")).not.toBeInTheDocument();
@@ -3260,7 +3260,7 @@ describe("Markra workspace", () => {
width: "360px"
});
expect(container.querySelector(".native-title-slot")).toHaveStyle({
- marginLeft: "196px"
+ marginLeft: "164px"
});
expect(container.querySelector(".markdown-file-tree")).toHaveStyle({
width: "360px"
@@ -3275,7 +3275,7 @@ describe("Markra workspace", () => {
gridTemplateColumns: "220px minmax(0,1fr)"
});
expect(container.querySelector(".native-title-slot")).toHaveStyle({
- marginLeft: "56px"
+ marginLeft: "24px"
});
expect(container.querySelector(".native-title-slot")).not.toHaveAttribute("data-tauri-drag-region");
expect(container.querySelector(".document-tabs-drag-spacer")).toHaveAttribute("data-tauri-drag-region");
@@ -6515,6 +6515,162 @@ describe("Markra workspace", () => {
expect(screen.getByLabelText("Markdown editor")).toHaveAttribute("data-editor-engine", "codemirror");
});
+ it("commits pending visual IME content before source mode mounts", async () => {
+ renderApp();
+
+ await expectVisibleMarkdownText("Welcome to Markra");
+ const visualEditor = screen.getByRole("textbox", { name: "Markdown document" });
+ const visualView = getMarkdownSourceView(visualEditor);
+ const pendingCompositionTasks: VoidFunction[] = [];
+ const queueMicrotaskSpy = vi
+ .spyOn(globalThis, "queueMicrotask")
+ .mockImplementation((task) => pendingCompositionTasks.push(task));
+ try {
+ act(() => {
+ fireEvent.compositionStart(visualEditor);
+ visualView.dispatch({
+ changes: {
+ from: 0,
+ insert: "# Pending visual edit\n\nNot published by the IME yet.",
+ to: visualView.state.doc.length
+ }
+ });
+ fireEvent.compositionEnd(visualEditor);
+ });
+ } finally {
+ queueMicrotaskSpy.mockRestore();
+ }
+
+ await selectEditorViewMode("Source code");
+
+ expect(readMarkdownSource(await screen.findByRole("textbox", { name: "Markdown source" }))).toBe(
+ "# Pending visual edit\n\nNot published by the IME yet."
+ );
+ act(() => pendingCompositionTasks.forEach((task) => task()));
+ });
+
+ it("keeps the active tab content across editor modes after a prior tab finishes IME composition", async () => {
+ const bulletinPath = "/mock-files/vault/bulletin.md";
+ const tempPath = "/mock-files/vault/temp.md";
+ mockedGetStoredWorkspaceState.mockResolvedValue({
+ aiAgentSessionId: "session-source-tabs",
+ filePath: bulletinPath,
+ fileTreeOpen: false,
+ folderName: null,
+ folderPath: null,
+ openFilePaths: [bulletinPath, tempPath]
+ });
+ mockedReadNativeMarkdownFile.mockImplementation(async (path) => path === bulletinPath
+ ? {
+ content: "# Bulletin\n\nFirst document.",
+ name: "bulletin.md",
+ path
+ }
+ : {
+ content: "# Temporary notes\n\nSecond document.",
+ name: "temp.md",
+ path
+ });
+
+ renderApp();
+
+ expect(await screen.findByRole("heading", { name: "Bulletin" })).toBeInTheDocument();
+ const bulletinEditor = screen.getByRole("textbox", { name: "Markdown document" });
+ const bulletinView = getMarkdownSourceView(bulletinEditor);
+ const pendingCompositionTasks: VoidFunction[] = [];
+ const queueMicrotaskSpy = vi
+ .spyOn(globalThis, "queueMicrotask")
+ .mockImplementation((task) => pendingCompositionTasks.push(task));
+ try {
+ act(() => {
+ fireEvent.compositionStart(bulletinEditor);
+ bulletinView.dispatch({
+ changes: {
+ from: 0,
+ insert: "# Bulletin\n\nFinished Chinese composition.",
+ to: bulletinView.state.doc.length
+ }
+ });
+ fireEvent.compositionEnd(bulletinEditor);
+ });
+ } finally {
+ queueMicrotaskSpy.mockRestore();
+ }
+ fireEvent.click(screen.getByRole("tab", { name: /temp\.md/ }));
+
+ await selectEditorViewMode("Source code");
+
+ expect(readMarkdownSource(await screen.findByRole("textbox", { name: "Markdown source" }))).toBe(
+ "# Temporary notes\n\nSecond document."
+ );
+ act(() => pendingCompositionTasks.forEach((task) => task()));
+ expect(readMarkdownSource(screen.getByRole("textbox", { name: "Markdown source" }))).toBe(
+ "# Temporary notes\n\nSecond document."
+ );
+
+ fireEvent.click(screen.getByRole("tab", { name: /bulletin\.md/ }));
+ expect(readMarkdownSource(await screen.findByRole("textbox", { name: "Markdown source" }))).toBe(
+ "# Bulletin\n\nFinished Chinese composition."
+ );
+ fireEvent.click(screen.getByRole("tab", { name: /temp\.md/ }));
+
+ await selectEditorViewMode("Preview + Source");
+
+ expect(readMarkdownSource(screen.getByRole("textbox", { name: "Markdown source" }))).toBe(
+ "# Temporary notes\n\nSecond document."
+ );
+ await expectVisibleCodeMirrorText(document.body, "Temporary notes");
+ expect(readMarkdownSource(screen.getByRole("textbox", { name: "Markdown document" }))).toBe(
+ "# Temporary notes\n\nSecond document."
+ );
+
+ await selectEditorViewMode("Preview");
+
+ await expectVisibleCodeMirrorText(document.body, "Temporary notes");
+ expect(readMarkdownSource(screen.getByRole("textbox", { name: "Markdown document" }))).toBe(
+ "# Temporary notes\n\nSecond document."
+ );
+ });
+
+ it("isolates source editor instances between document tabs", async () => {
+ const firstPath = "/mock-files/vault/first.md";
+ const secondPath = "/mock-files/vault/second.md";
+ mockedGetStoredWorkspaceState.mockResolvedValue({
+ aiAgentSessionId: "session-isolated-source-tabs",
+ filePath: firstPath,
+ fileTreeOpen: false,
+ folderName: null,
+ folderPath: null,
+ openFilePaths: [firstPath, secondPath]
+ });
+ mockedReadNativeMarkdownFile.mockImplementation(async (path) => path === firstPath
+ ? {
+ content: "# First document",
+ name: "first.md",
+ path
+ }
+ : {
+ content: "# Second document",
+ name: "second.md",
+ path
+ });
+
+ renderApp();
+
+ expect(await screen.findByRole("heading", { name: "First document" })).toBeInTheDocument();
+ await selectEditorViewMode("Source code");
+ const firstSourceView = getMarkdownSourceView(
+ await screen.findByRole("textbox", { name: "Markdown source" })
+ );
+
+ fireEvent.click(screen.getByRole("tab", { name: /second\.md/ }));
+
+ const secondSourceEditor = await screen.findByRole("textbox", { name: "Markdown source" });
+ const secondSourceView = getMarkdownSourceView(secondSourceEditor);
+ expect(secondSourceView).not.toBe(firstSourceView);
+ expect(secondSourceView.state.doc.toString()).toBe("# Second document");
+ });
+
it("shows optional line numbers in source and split source modes", async () => {
mockedGetStoredEditorPreferences.mockResolvedValue(createStoredEditorPreferences({
showLineNumbers: true
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index c79f7cf4..7c4dfbee 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -68,6 +68,7 @@ import { useMarkdownDocument, type ActiveDiskFileContentChange } from "./hooks/u
import { useMarkdownFileTree } from "./hooks/useMarkdownFileTree";
import { useSelectionToolbarAnchorRefresh } from "./hooks/useSelectionToolbarAnchorRefresh";
import { useSharedEditorHistory } from "./hooks/useSharedEditorHistory";
+import { routeMarkdownChangeToTab } from "./hooks/markdown-document/editor-sync";
import { useSideBySideTabs } from "./hooks/useSideBySideTabs";
import { useAutoUpdater } from "./hooks/useAutoUpdater";
import { useBackupSettings } from "./hooks/useBackupSettings";
@@ -1003,6 +1004,7 @@ function WorkspaceApp() {
syncSourceEditsToVisualHistory
} = useSharedEditorHistory({
documentContent: document.content,
+ documentKey: activeTabId,
documentRevision: document.revision,
largeMarkdownVisualBlocked,
replaceEditorMarkdown,
@@ -2939,7 +2941,11 @@ function WorkspaceApp() {
const handleSideDocumentPaneFocus = useCallback(() => {
setDocumentOperationTarget("side");
}, []);
- const handleVisualMarkdownChange = useCallback((content: string, options?: { documentRevision?: number }) => {
+ const handleVisualMarkdownTabChange = useCallback((
+ tabId: string,
+ content: string,
+ options?: { documentRevision?: number }
+ ) => {
if (isApplyingSourceToVisualSync()) return;
if (syncingExternalDocumentHistoryRef.current) return;
if (sourceMode) return;
@@ -2953,16 +2959,28 @@ function WorkspaceApp() {
}
if (splitMode && activeEditorSurface !== "source") setActiveEditorSurface("visual");
- handleMarkdownChange(content, { ...options, surface: "visual" });
+ // IME completion can publish after another tab becomes active, so preserve the
+ // originating tab identity instead of writing through the current-document path.
+ routeMarkdownChangeToTab({
+ content,
+ documentRevision: options?.documentRevision,
+ handleMarkdownTabChange,
+ surface: "visual",
+ tabId
+ });
}, [
activeEditorSurface,
- handleMarkdownChange,
+ handleMarkdownTabChange,
isApplyingSourceToVisualSync,
readOnlyMode,
sourceMode,
splitMode
]);
- const handleSourceMarkdownChange = useCallback((content: string, options?: { documentRevision?: number }) => {
+ const handleSourceMarkdownTabChange = useCallback((
+ tabId: string,
+ content: string,
+ options?: { documentRevision?: number }
+ ) => {
if (readOnlyMode) return;
if (
content !== document.content &&
@@ -2973,15 +2991,29 @@ function WorkspaceApp() {
markSourceEditForHistory(content, options);
if (splitMode) setActiveEditorSurface("source");
- handleMarkdownChange(content, { ...options, surface: "source" });
+ routeMarkdownChangeToTab({
+ content,
+ documentRevision: options?.documentRevision,
+ handleMarkdownTabChange,
+ surface: "source",
+ tabId
+ });
}, [
document.content,
document.revision,
- handleMarkdownChange,
+ handleMarkdownTabChange,
markSourceEditForHistory,
readOnlyMode,
splitMode
]);
+ const handleSourceMarkdownChange = useCallback((
+ content: string,
+ options?: { documentRevision?: number }
+ ) => {
+ if (!activeTabId) return;
+
+ handleSourceMarkdownTabChange(activeTabId, content, options);
+ }, [activeTabId, handleSourceMarkdownTabChange]);
const syncSplitPaneScrollPosition = useCallback((sourceSurface: EditorSurface, sourceElement: HTMLElement) => {
if (!splitMode) return false;
@@ -3499,11 +3531,36 @@ function WorkspaceApp() {
path: document.path
};
}, [activeImageFile, document.content, document.name, document.path, hasOpenDocument]);
+ const commitActiveVisualMarkdown = useCallback(() => {
+ if (!activeTabId) return;
+ if (editorMode === "source") return;
+ if (editorMode === "split" && activeEditorSurface === "source") return;
+
+ const activeVisualEditor = mainVisualEditorsRef.current.get(activeTabId);
+ if (!activeVisualEditor) return;
+
+ handleVisualMarkdownTabChange(
+ activeTabId,
+ getMarkdownFromEditor(activeVisualEditor, document.content),
+ { documentRevision: document.revision }
+ );
+ }, [
+ activeEditorSurface,
+ activeTabId,
+ document.content,
+ document.revision,
+ editorMode,
+ getMarkdownFromEditor,
+ handleVisualMarkdownTabChange
+ ]);
const handleEditorModeSelect = useCallback((nextMode: EditorMode) => {
if (!sourceModeAvailable) return;
if (nextMode === editorMode) return;
captureActiveDocumentViewState();
+ // IME changes can still be pending in the visual surface when source mode
+ // mounts, so snapshot the originating editor before changing surfaces.
+ commitActiveVisualMarkdown();
if (nextMode === "visual") {
if (sourceMode) syncSourceEditsToVisualHistory();
@@ -3530,6 +3587,7 @@ function WorkspaceApp() {
}, [
captureActiveDocumentViewState,
clearSideDocumentGroup,
+ commitActiveVisualMarkdown,
editorMode,
handleAiCommandClose,
queueEditorModeScroll,
@@ -4214,15 +4272,11 @@ function WorkspaceApp() {
onEditorReady={(readyEditor, disposedEditor) =>
handleMainVisualEditorReady(tab.id, readyEditor, disposedEditor)
}
- onMarkdownChange={(content) => {
- const options = { documentRevision: tab.revision };
- if (tabActive) {
- handleVisualMarkdownChange(content, options);
- return;
- }
-
- handleMarkdownTabChange(tab.id, content, { ...options, surface: "visual" });
- }}
+ onMarkdownChange={(content) => handleVisualMarkdownTabChange(
+ tab.id,
+ content,
+ { documentRevision: tab.revision }
+ )}
onContentWidthChange={editorWidthResizerVisible ? handleEditorContentWidthChange : undefined}
onContentWidthResizeEnd={editorWidthResizerVisible ? handleEditorContentWidthResizeEnd : undefined}
onSaveClipboardAttachment={handleSaveClipboardAttachment}
@@ -4605,6 +4659,7 @@ function WorkspaceApp() {
handleSourceMarkdownChange(content, {
- documentRevision: document.revision
- })}
+ onChange={(content) => handleSourceMarkdownTabChange(
+ activeTabId ?? "untitled:0",
+ content,
+ {
+ documentRevision: document.revision
+ }
+ )}
onContentWidthChange={editorWidthResizerVisible ? handleEditorContentWidthChange : undefined}
onContentWidthResizeEnd={editorWidthResizerVisible ? handleEditorContentWidthResizeEnd : undefined}
onScroll={handleSourcePaneScroll}
@@ -4641,6 +4700,7 @@ function WorkspaceApp() {
{mainVisualEditors}
{sourceMode ? (
handleSourceMarkdownChange(content, {
- documentRevision: document.revision
- })}
+ onChange={(content) => handleSourceMarkdownTabChange(
+ activeTabId ?? "untitled:0",
+ content,
+ {
+ documentRevision: document.revision
+ }
+ )}
onContentWidthChange={editorWidthResizerVisible ? handleEditorContentWidthChange : undefined}
onContentWidthResizeEnd={editorWidthResizerVisible ? handleEditorContentWidthResizeEnd : undefined}
onScroll={handleSourcePaneScroll}
diff --git a/packages/app/src/hooks/markdown-document/editor-sync.test.ts b/packages/app/src/hooks/markdown-document/editor-sync.test.ts
index 399c75e8..be0c5335 100644
--- a/packages/app/src/hooks/markdown-document/editor-sync.test.ts
+++ b/packages/app/src/hooks/markdown-document/editor-sync.test.ts
@@ -1,5 +1,8 @@
-import { describe, expect, it } from "vitest";
-import { createEditorSyncState } from "./editor-sync";
+import { describe, expect, it, vi } from "vitest";
+import {
+ createEditorSyncState,
+ routeMarkdownChangeToTab
+} from "./editor-sync";
describe("editor sync state", () => {
it("recognizes previous clean visual content as stale only after the newer content is saved", () => {
@@ -32,4 +35,46 @@ describe("editor sync state", () => {
"# Guide\n\nOriginal"
)).toBe(false);
});
+
+ it("routes a delayed visual editor change to the tab that produced it", () => {
+ const handleMarkdownTabChange = vi.fn();
+
+ routeMarkdownChangeToTab({
+ content: "# First document\n\nLate IME update.",
+ documentRevision: 7,
+ handleMarkdownTabChange,
+ surface: "visual",
+ tabId: "file:/mock-files/first.md"
+ });
+
+ expect(handleMarkdownTabChange).toHaveBeenCalledWith(
+ "file:/mock-files/first.md",
+ "# First document\n\nLate IME update.",
+ {
+ documentRevision: 7,
+ surface: "visual"
+ }
+ );
+ });
+
+ it("routes a delayed source editor change to the tab that produced it", () => {
+ const handleMarkdownTabChange = vi.fn();
+
+ routeMarkdownChangeToTab({
+ content: "# First document\n\nLate source update.",
+ documentRevision: 8,
+ handleMarkdownTabChange,
+ surface: "source",
+ tabId: "file:/mock-files/first.md"
+ });
+
+ expect(handleMarkdownTabChange).toHaveBeenCalledWith(
+ "file:/mock-files/first.md",
+ "# First document\n\nLate source update.",
+ {
+ documentRevision: 8,
+ surface: "source"
+ }
+ );
+ });
});
diff --git a/packages/app/src/hooks/markdown-document/editor-sync.ts b/packages/app/src/hooks/markdown-document/editor-sync.ts
index 9b5e9017..166aa32d 100644
--- a/packages/app/src/hooks/markdown-document/editor-sync.ts
+++ b/packages/app/src/hooks/markdown-document/editor-sync.ts
@@ -7,6 +7,34 @@ type SavedVisualEditorStaleContent = {
staleContent: string;
};
+type RouteMarkdownChangeToTabOptions = {
+ content: string;
+ documentRevision?: number;
+ handleMarkdownTabChange: (
+ tabId: string,
+ content: string,
+ options: {
+ documentRevision?: number;
+ surface: EditorSurface;
+ }
+ ) => unknown;
+ surface: EditorSurface;
+ tabId: string;
+};
+
+export function routeMarkdownChangeToTab({
+ content,
+ documentRevision,
+ handleMarkdownTabChange,
+ surface,
+ tabId
+}: RouteMarkdownChangeToTabOptions) {
+ return handleMarkdownTabChange(tabId, content, {
+ documentRevision,
+ surface
+ });
+}
+
export function createEditorSyncState() {
const cleanVisualContentBeforeDirty = new Map();
const cleanVisualMarkdownBaseline = new Map();
diff --git a/packages/app/src/hooks/useSharedEditorHistory.test.tsx b/packages/app/src/hooks/useSharedEditorHistory.test.tsx
index d456f535..9706b848 100644
--- a/packages/app/src/hooks/useSharedEditorHistory.test.tsx
+++ b/packages/app/src/hooks/useSharedEditorHistory.test.tsx
@@ -7,6 +7,7 @@ describe("useSharedEditorHistory", () => {
const { rerender } = renderHook(
({ sourceSurfaceActive }) => useSharedEditorHistory({
documentContent: "# Split\n\nEdited from visual.",
+ documentKey: "file:/mock-files/split.md",
documentRevision: 1,
largeMarkdownVisualBlocked: false,
replaceEditorMarkdown,
@@ -27,6 +28,7 @@ describe("useSharedEditorHistory", () => {
const { result, rerender } = renderHook(
({ documentContent, sourceSurfaceActive }) => useSharedEditorHistory({
documentContent,
+ documentKey: "file:/mock-files/split.md",
documentRevision: 1,
largeMarkdownVisualBlocked: false,
replaceEditorMarkdown,
@@ -55,4 +57,39 @@ describe("useSharedEditorHistory", () => {
historyBaselineMarkdown: "# Split\n\nOriginal."
});
});
+
+ it("clears pending source history when the active document changes", () => {
+ const replaceEditorMarkdown = vi.fn(() => true);
+ const { result, rerender } = renderHook(
+ ({ documentContent, documentKey }) => useSharedEditorHistory({
+ documentContent,
+ documentKey,
+ documentRevision: 1,
+ largeMarkdownVisualBlocked: false,
+ replaceEditorMarkdown,
+ sourceSurfaceActive: true,
+ syncSourceToVisual: false,
+ visualEditorReadySequence: 1
+ }),
+ {
+ initialProps: {
+ documentContent: "# First\n\nOriginal.",
+ documentKey: "file:/mock-files/first.md"
+ }
+ }
+ );
+
+ act(() => {
+ result.current.markSourceEditForHistory("# First\n\nEdited.", { documentRevision: 1 });
+ });
+ rerender({
+ documentContent: "# Second\n\nOriginal.",
+ documentKey: "file:/mock-files/second.md"
+ });
+
+ act(() => {
+ expect(result.current.syncSourceEditsToVisualHistory()).toBe(false);
+ });
+ expect(replaceEditorMarkdown).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/app/src/hooks/useSharedEditorHistory.ts b/packages/app/src/hooks/useSharedEditorHistory.ts
index e62367cd..d001fbba 100644
--- a/packages/app/src/hooks/useSharedEditorHistory.ts
+++ b/packages/app/src/hooks/useSharedEditorHistory.ts
@@ -18,6 +18,7 @@ type ReplaceEditorMarkdown = (
type SharedEditorHistoryOptions = {
documentContent: string;
+ documentKey: string | null;
documentRevision: number;
largeMarkdownVisualBlocked: boolean;
replaceEditorMarkdown: ReplaceEditorMarkdown;
@@ -28,6 +29,7 @@ type SharedEditorHistoryOptions = {
export function useSharedEditorHistory({
documentContent,
+ documentKey,
documentRevision,
largeMarkdownVisualBlocked,
replaceEditorMarkdown,
@@ -36,8 +38,16 @@ export function useSharedEditorHistory({
visualEditorReadySequence
}: SharedEditorHistoryOptions) {
const pendingSourceHistoryRef = useRef(null);
+ const pendingSourceHistoryDocumentKeyRef = useRef(documentKey);
const syncingSourceToVisualRef = useRef(false);
+ if (pendingSourceHistoryDocumentKeyRef.current !== documentKey) {
+ // A pending baseline belongs to one tab; carrying it across a tab switch
+ // would splice the previous document into the current document's undo stack.
+ pendingSourceHistoryDocumentKeyRef.current = documentKey;
+ pendingSourceHistoryRef.current = null;
+ }
+
const isApplyingSourceToVisualSync = useCallback(() => syncingSourceToVisualRef.current, []);
const markSourceEditForHistory = useCallback((content: string, options?: MarkdownChangeOptions) => {
if (
diff --git a/packages/shared/src/i18n/locales/de.ts b/packages/shared/src/i18n/locales/de.ts
index 66632413..dca74c06 100644
--- a/packages/shared/src/i18n/locales/de.ts
+++ b/packages/shared/src/i18n/locales/de.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "Schritte",
"app.clipboardAttachmentRequiresSavedDocument": "Speichern Sie das Dokument, bevor Sie Dateianhaenge hinzufuegen.",
"app.clipboardAttachmentSaveFailed": "Der Dateianhang konnte nicht gespeichert werden.",
+ "app.assetCleanup.title": "Unbenutzte Bilder bereinigen",
+ "app.assetCleanup.close": "Bildbereinigung schließen",
+ "app.assetCleanup.scanning": "Markdown-Verweise werden durchsucht...",
+ "app.assetCleanup.refresh": "Erneut scannen",
+ "app.assetCleanup.scanned": "{count} Markdown-Dateien durchsucht",
+ "app.assetCleanup.unusedSingular": "{count} unbenutztes Bild",
+ "app.assetCleanup.unusedPlural": "{count} unbenutzte Bilder",
+ "app.assetCleanup.unreadableSingular": "{count} Markdown-Datei konnte nicht gelesen werden. Die Bereinigung ist deaktiviert.",
+ "app.assetCleanup.unreadablePlural": "{count} Markdown-Dateien konnten nicht gelesen werden. Die Bereinigung ist deaktiviert.",
+ "app.assetCleanup.recent": "Kürzlich hinzugefügt",
+ "app.assetCleanup.empty": "Keine unbenutzten verwalteten Bilder gefunden.",
+ "app.assetCleanup.trashSingular": "{count} Bild in den Papierkorb verschieben",
+ "app.assetCleanup.trashPlural": "{count} Bilder in den Papierkorb verschieben",
+ "app.assetCleanup.trashing": "Wird in den Papierkorb verschoben...",
+ "app.assetCleanup.error.scan": "Der Arbeitsbereich konnte nicht durchsucht werden. Es wurden keine Bilder geändert.",
+ "app.assetCleanup.error.changed": "Der Arbeitsbereich wurde während der Bereinigung geändert. Prüfen Sie die aktualisierten Ergebnisse, bevor Sie es erneut versuchen.",
+ "app.assetCleanup.error.trash": "Einige Bilder konnten nicht in den Papierkorb verschoben werden.",
"menu.file": "Datei",
"menu.edit": "Bearbeiten",
"menu.format": "Format",
diff --git a/packages/shared/src/i18n/locales/es.ts b/packages/shared/src/i18n/locales/es.ts
index 2eb78c74..5f841602 100644
--- a/packages/shared/src/i18n/locales/es.ts
+++ b/packages/shared/src/i18n/locales/es.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "pasos",
"app.clipboardAttachmentRequiresSavedDocument": "Guarda el documento antes de agregar archivos adjuntos.",
"app.clipboardAttachmentSaveFailed": "No se pudo guardar el archivo adjunto.",
+ "app.assetCleanup.title": "Limpiar imágenes sin usar",
+ "app.assetCleanup.close": "Cerrar limpieza de imágenes",
+ "app.assetCleanup.scanning": "Buscando referencias Markdown...",
+ "app.assetCleanup.refresh": "Volver a analizar",
+ "app.assetCleanup.scanned": "Se analizaron {count} archivos Markdown",
+ "app.assetCleanup.unusedSingular": "{count} imagen sin usar",
+ "app.assetCleanup.unusedPlural": "{count} imágenes sin usar",
+ "app.assetCleanup.unreadableSingular": "No se pudo leer {count} archivo Markdown. La limpieza está desactivada.",
+ "app.assetCleanup.unreadablePlural": "No se pudieron leer {count} archivos Markdown. La limpieza está desactivada.",
+ "app.assetCleanup.recent": "Recientes",
+ "app.assetCleanup.empty": "No se encontraron imágenes administradas sin usar.",
+ "app.assetCleanup.trashSingular": "Mover {count} imagen a la papelera",
+ "app.assetCleanup.trashPlural": "Mover {count} imágenes a la papelera",
+ "app.assetCleanup.trashing": "Moviendo a la papelera...",
+ "app.assetCleanup.error.scan": "No se pudo analizar el espacio de trabajo. No se modificó ninguna imagen.",
+ "app.assetCleanup.error.changed": "El espacio de trabajo cambió durante la limpieza. Revisa los resultados actualizados antes de volver a intentarlo.",
+ "app.assetCleanup.error.trash": "No se pudieron mover algunas imágenes a la papelera.",
"menu.file": "Archivo",
"menu.edit": "Editar",
"menu.format": "Formato",
diff --git a/packages/shared/src/i18n/locales/fr.ts b/packages/shared/src/i18n/locales/fr.ts
index 7890fac7..40151bad 100644
--- a/packages/shared/src/i18n/locales/fr.ts
+++ b/packages/shared/src/i18n/locales/fr.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "étapes",
"app.clipboardAttachmentRequiresSavedDocument": "Enregistrez le document avant d'ajouter des pieces jointes.",
"app.clipboardAttachmentSaveFailed": "Impossible d'enregistrer la piece jointe.",
+ "app.assetCleanup.title": "Nettoyer les images inutilisées",
+ "app.assetCleanup.close": "Fermer le nettoyage des images",
+ "app.assetCleanup.scanning": "Analyse des références Markdown...",
+ "app.assetCleanup.refresh": "Analyser à nouveau",
+ "app.assetCleanup.scanned": "{count} fichiers Markdown analysés",
+ "app.assetCleanup.unusedSingular": "{count} image inutilisée",
+ "app.assetCleanup.unusedPlural": "{count} images inutilisées",
+ "app.assetCleanup.unreadableSingular": "{count} fichier Markdown n’a pas pu être lu. Le nettoyage est désactivé.",
+ "app.assetCleanup.unreadablePlural": "{count} fichiers Markdown n’ont pas pu être lus. Le nettoyage est désactivé.",
+ "app.assetCleanup.recent": "Récentes",
+ "app.assetCleanup.empty": "Aucune image gérée inutilisée n’a été trouvée.",
+ "app.assetCleanup.trashSingular": "Déplacer {count} image vers la corbeille",
+ "app.assetCleanup.trashPlural": "Déplacer {count} images vers la corbeille",
+ "app.assetCleanup.trashing": "Déplacement vers la corbeille...",
+ "app.assetCleanup.error.scan": "Impossible d’analyser l’espace de travail. Aucune image n’a été modifiée.",
+ "app.assetCleanup.error.changed": "L’espace de travail a changé pendant le nettoyage. Vérifiez les résultats actualisés avant de réessayer.",
+ "app.assetCleanup.error.trash": "Certaines images n’ont pas pu être déplacées vers la corbeille.",
"menu.file": "Fichier",
"menu.edit": "Édition",
"menu.format": "Format",
diff --git a/packages/shared/src/i18n/locales/it.ts b/packages/shared/src/i18n/locales/it.ts
index 542399b9..bdd3f9d3 100644
--- a/packages/shared/src/i18n/locales/it.ts
+++ b/packages/shared/src/i18n/locales/it.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "passaggi",
"app.clipboardAttachmentRequiresSavedDocument": "Salva il documento prima di aggiungere allegati file.",
"app.clipboardAttachmentSaveFailed": "Impossibile salvare l'allegato file.",
+ "app.assetCleanup.title": "Pulisci immagini inutilizzate",
+ "app.assetCleanup.close": "Chiudi pulizia immagini",
+ "app.assetCleanup.scanning": "Scansione dei riferimenti Markdown...",
+ "app.assetCleanup.refresh": "Scansiona di nuovo",
+ "app.assetCleanup.scanned": "Analizzati {count} file Markdown",
+ "app.assetCleanup.unusedSingular": "{count} immagine inutilizzata",
+ "app.assetCleanup.unusedPlural": "{count} immagini inutilizzate",
+ "app.assetCleanup.unreadableSingular": "Impossibile leggere {count} file Markdown. La pulizia è disattivata.",
+ "app.assetCleanup.unreadablePlural": "Impossibile leggere {count} file Markdown. La pulizia è disattivata.",
+ "app.assetCleanup.recent": "Recenti",
+ "app.assetCleanup.empty": "Non sono state trovate immagini gestite inutilizzate.",
+ "app.assetCleanup.trashSingular": "Sposta {count} immagine nel Cestino",
+ "app.assetCleanup.trashPlural": "Sposta {count} immagini nel Cestino",
+ "app.assetCleanup.trashing": "Spostamento nel Cestino...",
+ "app.assetCleanup.error.scan": "Impossibile analizzare l'area di lavoro. Nessuna immagine è stata modificata.",
+ "app.assetCleanup.error.changed": "L'area di lavoro è cambiata durante la pulizia. Controlla i risultati aggiornati prima di riprovare.",
+ "app.assetCleanup.error.trash": "Non è stato possibile spostare alcune immagini nel Cestino.",
"menu.file": "File",
"menu.edit": "Modifica",
"menu.format": "Formato",
diff --git a/packages/shared/src/i18n/locales/ja.ts b/packages/shared/src/i18n/locales/ja.ts
index c97e79b3..bf586bce 100644
--- a/packages/shared/src/i18n/locales/ja.ts
+++ b/packages/shared/src/i18n/locales/ja.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "ステップ",
"app.clipboardAttachmentRequiresSavedDocument": "ファイル添付を追加する前にドキュメントを保存してください。",
"app.clipboardAttachmentSaveFailed": "ファイル添付を保存できませんでした。",
+ "app.assetCleanup.title": "未使用の画像をクリーンアップ",
+ "app.assetCleanup.close": "画像クリーンアップを閉じる",
+ "app.assetCleanup.scanning": "Markdown の参照をスキャン中...",
+ "app.assetCleanup.refresh": "再スキャン",
+ "app.assetCleanup.scanned": "{count} 件の Markdown ファイルをスキャンしました",
+ "app.assetCleanup.unusedSingular": "未使用の画像 {count} 件",
+ "app.assetCleanup.unusedPlural": "未使用の画像 {count} 件",
+ "app.assetCleanup.unreadableSingular": "{count} 件の Markdown ファイルを読み込めませんでした。クリーンアップは無効です。",
+ "app.assetCleanup.unreadablePlural": "{count} 件の Markdown ファイルを読み込めませんでした。クリーンアップは無効です。",
+ "app.assetCleanup.recent": "最近",
+ "app.assetCleanup.empty": "未使用の管理対象画像は見つかりませんでした。",
+ "app.assetCleanup.trashSingular": "{count} 件の画像をゴミ箱に移動",
+ "app.assetCleanup.trashPlural": "{count} 件の画像をゴミ箱に移動",
+ "app.assetCleanup.trashing": "ゴミ箱に移動中...",
+ "app.assetCleanup.error.scan": "ワークスペースをスキャンできませんでした。画像は変更されていません。",
+ "app.assetCleanup.error.changed": "クリーンアップ中にワークスペースが変更されました。更新された結果を確認してから再試行してください。",
+ "app.assetCleanup.error.trash": "一部の画像をゴミ箱に移動できませんでした。",
"menu.file": "ファイル",
"menu.edit": "編集",
"menu.format": "フォーマット",
diff --git a/packages/shared/src/i18n/locales/ko.ts b/packages/shared/src/i18n/locales/ko.ts
index 58c94692..b1e913ef 100644
--- a/packages/shared/src/i18n/locales/ko.ts
+++ b/packages/shared/src/i18n/locales/ko.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "단계",
"app.clipboardAttachmentRequiresSavedDocument": "파일 첨부를 추가하기 전에 문서를 저장하세요.",
"app.clipboardAttachmentSaveFailed": "파일 첨부를 저장할 수 없습니다.",
+ "app.assetCleanup.title": "사용하지 않는 이미지 정리",
+ "app.assetCleanup.close": "이미지 정리 닫기",
+ "app.assetCleanup.scanning": "Markdown 참조 검색 중...",
+ "app.assetCleanup.refresh": "다시 검색",
+ "app.assetCleanup.scanned": "Markdown 파일 {count}개 검색됨",
+ "app.assetCleanup.unusedSingular": "사용하지 않는 이미지 {count}개",
+ "app.assetCleanup.unusedPlural": "사용하지 않는 이미지 {count}개",
+ "app.assetCleanup.unreadableSingular": "Markdown 파일 {count}개를 읽을 수 없습니다. 정리가 비활성화되었습니다.",
+ "app.assetCleanup.unreadablePlural": "Markdown 파일 {count}개를 읽을 수 없습니다. 정리가 비활성화되었습니다.",
+ "app.assetCleanup.recent": "최근",
+ "app.assetCleanup.empty": "사용하지 않는 관리 이미지가 없습니다.",
+ "app.assetCleanup.trashSingular": "이미지 {count}개를 휴지통으로 이동",
+ "app.assetCleanup.trashPlural": "이미지 {count}개를 휴지통으로 이동",
+ "app.assetCleanup.trashing": "휴지통으로 이동 중...",
+ "app.assetCleanup.error.scan": "워크스페이스를 검색할 수 없습니다. 변경된 이미지가 없습니다.",
+ "app.assetCleanup.error.changed": "정리 중 워크스페이스가 변경되었습니다. 새로 고친 결과를 확인한 후 다시 시도하세요.",
+ "app.assetCleanup.error.trash": "일부 이미지를 휴지통으로 이동할 수 없습니다.",
"menu.file": "파일",
"menu.edit": "편집",
"menu.format": "서식",
diff --git a/packages/shared/src/i18n/locales/pt-BR.ts b/packages/shared/src/i18n/locales/pt-BR.ts
index 127e30b0..7be5173c 100644
--- a/packages/shared/src/i18n/locales/pt-BR.ts
+++ b/packages/shared/src/i18n/locales/pt-BR.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "etapas",
"app.clipboardAttachmentRequiresSavedDocument": "Salve o documento antes de adicionar anexos de arquivo.",
"app.clipboardAttachmentSaveFailed": "Nao foi possivel salvar o anexo de arquivo.",
+ "app.assetCleanup.title": "Limpar imagens não utilizadas",
+ "app.assetCleanup.close": "Fechar limpeza de imagens",
+ "app.assetCleanup.scanning": "Verificando referências Markdown...",
+ "app.assetCleanup.refresh": "Verificar novamente",
+ "app.assetCleanup.scanned": "{count} arquivos Markdown verificados",
+ "app.assetCleanup.unusedSingular": "{count} imagem não utilizada",
+ "app.assetCleanup.unusedPlural": "{count} imagens não utilizadas",
+ "app.assetCleanup.unreadableSingular": "Não foi possível ler {count} arquivo Markdown. A limpeza está desativada.",
+ "app.assetCleanup.unreadablePlural": "Não foi possível ler {count} arquivos Markdown. A limpeza está desativada.",
+ "app.assetCleanup.recent": "Recentes",
+ "app.assetCleanup.empty": "Nenhuma imagem gerenciada não utilizada foi encontrada.",
+ "app.assetCleanup.trashSingular": "Mover {count} imagem para a Lixeira",
+ "app.assetCleanup.trashPlural": "Mover {count} imagens para a Lixeira",
+ "app.assetCleanup.trashing": "Movendo para a Lixeira...",
+ "app.assetCleanup.error.scan": "Não foi possível verificar o workspace. Nenhuma imagem foi alterada.",
+ "app.assetCleanup.error.changed": "O workspace foi alterado durante a limpeza. Revise os resultados atualizados antes de tentar novamente.",
+ "app.assetCleanup.error.trash": "Não foi possível mover algumas imagens para a Lixeira.",
"menu.file": "Arquivo",
"menu.edit": "Editar",
"menu.format": "Formatar",
diff --git a/packages/shared/src/i18n/locales/ru.ts b/packages/shared/src/i18n/locales/ru.ts
index 540fe3e5..9408f432 100644
--- a/packages/shared/src/i18n/locales/ru.ts
+++ b/packages/shared/src/i18n/locales/ru.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "шагов",
"app.clipboardAttachmentRequiresSavedDocument": "Сохраните документ перед добавлением файловых вложений.",
"app.clipboardAttachmentSaveFailed": "Не удалось сохранить файловое вложение.",
+ "app.assetCleanup.title": "Очистить неиспользуемые изображения",
+ "app.assetCleanup.close": "Закрыть очистку изображений",
+ "app.assetCleanup.scanning": "Поиск ссылок в Markdown...",
+ "app.assetCleanup.refresh": "Сканировать снова",
+ "app.assetCleanup.scanned": "Просканировано файлов Markdown: {count}",
+ "app.assetCleanup.unusedSingular": "{count} неиспользуемое изображение",
+ "app.assetCleanup.unusedPlural": "{count} неиспользуемых изображений",
+ "app.assetCleanup.unreadableSingular": "Не удалось прочитать {count} файл Markdown. Очистка отключена.",
+ "app.assetCleanup.unreadablePlural": "Не удалось прочитать {count} файлов Markdown. Очистка отключена.",
+ "app.assetCleanup.recent": "Недавние",
+ "app.assetCleanup.empty": "Неиспользуемые управляемые изображения не найдены.",
+ "app.assetCleanup.trashSingular": "Переместить {count} изображение в корзину",
+ "app.assetCleanup.trashPlural": "Переместить {count} изображений в корзину",
+ "app.assetCleanup.trashing": "Перемещение в корзину...",
+ "app.assetCleanup.error.scan": "Не удалось просканировать рабочую область. Изображения не были изменены.",
+ "app.assetCleanup.error.changed": "Рабочая область изменилась во время очистки. Проверьте обновлённые результаты перед повторной попыткой.",
+ "app.assetCleanup.error.trash": "Некоторые изображения не удалось переместить в корзину.",
"menu.file": "Файл",
"menu.edit": "Правка",
"menu.format": "Формат",
diff --git a/packages/shared/src/i18n/locales/zh-TW.ts b/packages/shared/src/i18n/locales/zh-TW.ts
index ffb77097..1cc84442 100644
--- a/packages/shared/src/i18n/locales/zh-TW.ts
+++ b/packages/shared/src/i18n/locales/zh-TW.ts
@@ -862,6 +862,23 @@ const messages: LocaleMessages = {
"app.aiAgentProcessSteps": "步",
"app.clipboardAttachmentRequiresSavedDocument": "請先儲存文件,再加入檔案附件。",
"app.clipboardAttachmentSaveFailed": "無法儲存檔案附件。",
+ "app.assetCleanup.title": "清理未引用圖片",
+ "app.assetCleanup.close": "關閉圖片清理",
+ "app.assetCleanup.scanning": "正在掃描 Markdown 引用...",
+ "app.assetCleanup.refresh": "重新掃描",
+ "app.assetCleanup.scanned": "已掃描 {count} 個 Markdown 檔案",
+ "app.assetCleanup.unusedSingular": "{count} 張未引用圖片",
+ "app.assetCleanup.unusedPlural": "{count} 張未引用圖片",
+ "app.assetCleanup.unreadableSingular": "有 {count} 個 Markdown 檔案無法讀取,已停用清理。",
+ "app.assetCleanup.unreadablePlural": "有 {count} 個 Markdown 檔案無法讀取,已停用清理。",
+ "app.assetCleanup.recent": "最近新增",
+ "app.assetCleanup.empty": "沒有發現未引用的託管圖片。",
+ "app.assetCleanup.trashSingular": "將 {count} 張圖片移到垃圾桶",
+ "app.assetCleanup.trashPlural": "將 {count} 張圖片移到垃圾桶",
+ "app.assetCleanup.trashing": "正在移到垃圾桶...",
+ "app.assetCleanup.error.scan": "無法掃描工作區,沒有修改任何圖片。",
+ "app.assetCleanup.error.changed": "清理期間工作區發生變化,請檢查重新整理後的結果再重試。",
+ "app.assetCleanup.error.trash": "部分圖片無法移到垃圾桶。",
"menu.file": "檔案",
"menu.edit": "編輯",
"menu.format": "格式",