feat: 优化桌面文件默认保存路径
This commit is contained in:
@@ -75,6 +75,7 @@ import {
|
||||
type WindowState
|
||||
} from "./window-state.js";
|
||||
import { getWindowCloseDecision } from "./window-close-policy.js";
|
||||
import { SaveLocationStore } from "./save-location-state.js";
|
||||
|
||||
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
||||
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
||||
@@ -113,6 +114,8 @@ export interface DesktopApplicationControllerOptions {
|
||||
iconPath: string;
|
||||
themeDirectory: string;
|
||||
windowStatePath: string;
|
||||
saveLocationStatePath: string;
|
||||
documentsDirectory: string;
|
||||
}
|
||||
|
||||
function focusWindow(window: BrowserWindow) {
|
||||
@@ -134,6 +137,7 @@ export class DesktopApplicationController {
|
||||
readonly #iconPath: string;
|
||||
readonly #themeDirectory: string;
|
||||
readonly #windowStatePath: string;
|
||||
readonly #saveLocationStore: SaveLocationStore;
|
||||
readonly #pdfGenerator: ElectronPdfGenerator;
|
||||
readonly #docxExportService: DocxExportService;
|
||||
readonly #docxMediaEngine: ElectronDocxMediaEngine;
|
||||
@@ -153,6 +157,10 @@ export class DesktopApplicationController {
|
||||
this.#iconPath = options.iconPath;
|
||||
this.#themeDirectory = options.themeDirectory;
|
||||
this.#windowStatePath = options.windowStatePath;
|
||||
this.#saveLocationStore = new SaveLocationStore(
|
||||
options.saveLocationStatePath,
|
||||
options.documentsDirectory
|
||||
);
|
||||
this.#pdfGenerator = new ElectronPdfGenerator({
|
||||
renderUrl: options.renderUrl,
|
||||
preloadPath: options.pdfPreloadPath
|
||||
@@ -181,12 +189,22 @@ export class DesktopApplicationController {
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.#savedWindowState = await loadWindowState(
|
||||
this.#windowStatePath
|
||||
);
|
||||
const [savedWindowState] = await Promise.all([
|
||||
loadWindowState(this.#windowStatePath),
|
||||
this.#saveLocationStore.initialize()
|
||||
]);
|
||||
this.#savedWindowState = savedWindowState;
|
||||
this.#registerIpcHandlers();
|
||||
}
|
||||
|
||||
async #recordSuccessfulFile(filePath: string) {
|
||||
await this.#saveLocationStore
|
||||
.recordSuccessfulFile(filePath)
|
||||
.catch((error: unknown) => {
|
||||
console.warn("无法持久化最近保存目录", error);
|
||||
});
|
||||
}
|
||||
|
||||
async createWindow(filePath?: string) {
|
||||
let pendingSnapshot: MarkdownFileSnapshot | undefined;
|
||||
if (filePath) {
|
||||
@@ -733,9 +751,10 @@ export class DesktopApplicationController {
|
||||
? "另存为 Markdown"
|
||||
: "保存 Markdown",
|
||||
defaultPath:
|
||||
forceSaveAs && session.currentFilePath
|
||||
? session.currentFilePath
|
||||
: path.join(app.getPath("documents"), suggestedName),
|
||||
await this.#saveLocationStore.resolveDefaultPath(
|
||||
session.currentFilePath,
|
||||
suggestedName
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
name: "Markdown 文件",
|
||||
@@ -788,6 +807,7 @@ export class DesktopApplicationController {
|
||||
throw new Error("该 Markdown 已在另一个窗口中打开");
|
||||
}
|
||||
this.#commitSnapshot(session, snapshot);
|
||||
await this.#recordSuccessfulFile(targetPath);
|
||||
return { fileName: snapshot.document.fileName };
|
||||
};
|
||||
|
||||
@@ -905,16 +925,18 @@ export class DesktopApplicationController {
|
||||
: `${path.basename(unsafeFileName)}.pdf`;
|
||||
const selection = await dialog.showSaveDialog(session.window, {
|
||||
title: "导出 PDF",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
suggestedName
|
||||
),
|
||||
defaultPath:
|
||||
await this.#saveLocationStore.resolveDefaultPath(
|
||||
session.currentFilePath,
|
||||
suggestedName
|
||||
),
|
||||
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
|
||||
});
|
||||
if (selection.canceled || !selection.filePath) {
|
||||
return false;
|
||||
}
|
||||
await writeFile(selection.filePath, unsafePdf);
|
||||
await this.#recordSuccessfulFile(selection.filePath);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
@@ -971,10 +993,11 @@ export class DesktopApplicationController {
|
||||
session.window,
|
||||
{
|
||||
title: "导出 DOCX",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
generated.fileName
|
||||
),
|
||||
defaultPath:
|
||||
await this.#saveLocationStore.resolveDefaultPath(
|
||||
session.currentFilePath,
|
||||
generated.fileName
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
name: "Word 文档",
|
||||
@@ -996,6 +1019,7 @@ export class DesktopApplicationController {
|
||||
selection.filePath,
|
||||
generated.docx
|
||||
);
|
||||
await this.#recordSuccessfulFile(saved.targetPath);
|
||||
return {
|
||||
ok: true,
|
||||
saved: true,
|
||||
|
||||
@@ -337,7 +337,12 @@ if (!hasSingleInstanceLock) {
|
||||
windowStatePath: path.join(
|
||||
app.getPath("userData"),
|
||||
"window-state.json"
|
||||
)
|
||||
),
|
||||
saveLocationStatePath: path.join(
|
||||
app.getPath("userData"),
|
||||
"save-location.json"
|
||||
),
|
||||
documentsDirectory: app.getPath("documents")
|
||||
});
|
||||
await applicationController.initialize();
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const SAVE_LOCATION_STATE_VERSION = 1;
|
||||
|
||||
export interface SaveLocationState {
|
||||
version: 1;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ResolveDefaultSavePathOptions {
|
||||
sourceFilePath?: string;
|
||||
lastSaveDirectory?: string;
|
||||
documentsDirectory: string;
|
||||
suggestedFileName: string;
|
||||
}
|
||||
|
||||
export function parseSaveLocationState(
|
||||
value: unknown
|
||||
): SaveLocationState | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const state = value as Partial<SaveLocationState>;
|
||||
if (
|
||||
state.version !== SAVE_LOCATION_STATE_VERSION ||
|
||||
typeof state.directory !== "string" ||
|
||||
!path.isAbsolute(state.directory)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: SAVE_LOCATION_STATE_VERSION,
|
||||
directory: path.resolve(state.directory)
|
||||
};
|
||||
}
|
||||
|
||||
async function isAvailableDirectory(directory: string | undefined) {
|
||||
if (!directory || !path.isAbsolute(directory)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await stat(directory)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuggestedFileName(value: string) {
|
||||
const fileName = path.basename(value.trim());
|
||||
if (!fileName || fileName === ".") {
|
||||
throw new Error("默认保存文件名无效");
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
export async function resolveDefaultSavePath(
|
||||
options: ResolveDefaultSavePathOptions
|
||||
) {
|
||||
const fileName = assertSuggestedFileName(
|
||||
options.suggestedFileName
|
||||
);
|
||||
const sourceDirectory = options.sourceFilePath
|
||||
? path.dirname(path.resolve(options.sourceFilePath))
|
||||
: undefined;
|
||||
if (await isAvailableDirectory(sourceDirectory)) {
|
||||
return path.join(sourceDirectory!, fileName);
|
||||
}
|
||||
if (await isAvailableDirectory(options.lastSaveDirectory)) {
|
||||
return path.join(options.lastSaveDirectory!, fileName);
|
||||
}
|
||||
return path.join(
|
||||
path.resolve(options.documentsDirectory),
|
||||
fileName
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSaveLocationState(filePath: string) {
|
||||
try {
|
||||
return parseSaveLocationState(
|
||||
JSON.parse(await readFile(filePath, "utf8")) as unknown
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSaveLocationState(
|
||||
filePath: string,
|
||||
directory: string
|
||||
) {
|
||||
const state = parseSaveLocationState({
|
||||
version: SAVE_LOCATION_STATE_VERSION,
|
||||
directory
|
||||
});
|
||||
if (!state) {
|
||||
throw new Error("最近保存目录无效");
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(state, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
await rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export class SaveLocationStore {
|
||||
readonly #statePath: string;
|
||||
readonly #documentsDirectory: string;
|
||||
#lastSaveDirectory: string | undefined;
|
||||
#pendingWrite = Promise.resolve();
|
||||
|
||||
constructor(statePath: string, documentsDirectory: string) {
|
||||
this.#statePath = statePath;
|
||||
this.#documentsDirectory = documentsDirectory;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.#lastSaveDirectory = (
|
||||
await loadSaveLocationState(this.#statePath)
|
||||
)?.directory;
|
||||
}
|
||||
|
||||
resolveDefaultPath(
|
||||
sourceFilePath: string | undefined,
|
||||
suggestedFileName: string
|
||||
) {
|
||||
return resolveDefaultSavePath({
|
||||
documentsDirectory: this.#documentsDirectory,
|
||||
suggestedFileName,
|
||||
...(sourceFilePath ? { sourceFilePath } : {}),
|
||||
...(this.#lastSaveDirectory
|
||||
? { lastSaveDirectory: this.#lastSaveDirectory }
|
||||
: {})
|
||||
});
|
||||
}
|
||||
|
||||
async recordSuccessfulFile(filePath: string) {
|
||||
const directory = path.dirname(path.resolve(filePath));
|
||||
this.#lastSaveDirectory = directory;
|
||||
const write = this.#pendingWrite
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
saveSaveLocationState(this.#statePath, directory)
|
||||
);
|
||||
this.#pendingWrite = write.then(() => undefined);
|
||||
await write;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
SaveLocationStore,
|
||||
loadSaveLocationState,
|
||||
parseSaveLocationState,
|
||||
resolveDefaultSavePath,
|
||||
saveSaveLocationState
|
||||
} from "../src/save-location-state.js";
|
||||
|
||||
let temporaryDirectory: string | undefined;
|
||||
|
||||
async function createTemporaryDirectory() {
|
||||
temporaryDirectory = await mkdtemp(
|
||||
path.join(tmpdir(), "md-to-pdf-save-location-")
|
||||
);
|
||||
return temporaryDirectory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (temporaryDirectory) {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
temporaryDirectory = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
describe("桌面默认保存位置", () => {
|
||||
it("只接受版本匹配的绝对目录状态", () => {
|
||||
expect(
|
||||
parseSaveLocationState({
|
||||
version: 1,
|
||||
directory: path.resolve("C:/Documents")
|
||||
})
|
||||
).toEqual({
|
||||
version: 1,
|
||||
directory: path.resolve("C:/Documents")
|
||||
});
|
||||
expect(
|
||||
parseSaveLocationState({ version: 2, directory: "C:/Documents" })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseSaveLocationState({ version: 1, directory: "relative" })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("优先使用有效的源文件目录", async () => {
|
||||
const root = await createTemporaryDirectory();
|
||||
const sourceDirectory = path.join(root, "source");
|
||||
const historyDirectory = path.join(root, "history");
|
||||
const documentsDirectory = path.join(root, "documents");
|
||||
await Promise.all([
|
||||
mkdir(sourceDirectory),
|
||||
mkdir(historyDirectory),
|
||||
mkdir(documentsDirectory)
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveDefaultSavePath({
|
||||
sourceFilePath: path.join(sourceDirectory, "source.md"),
|
||||
lastSaveDirectory: historyDirectory,
|
||||
documentsDirectory,
|
||||
suggestedFileName: "report.pdf"
|
||||
})
|
||||
).resolves.toBe(path.join(sourceDirectory, "report.pdf"));
|
||||
});
|
||||
|
||||
it("源目录不可用时使用最近成功目录", async () => {
|
||||
const root = await createTemporaryDirectory();
|
||||
const historyDirectory = path.join(root, "history");
|
||||
const documentsDirectory = path.join(root, "documents");
|
||||
await Promise.all([
|
||||
mkdir(historyDirectory),
|
||||
mkdir(documentsDirectory)
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveDefaultSavePath({
|
||||
sourceFilePath: path.join(root, "missing", "source.md"),
|
||||
lastSaveDirectory: historyDirectory,
|
||||
documentsDirectory,
|
||||
suggestedFileName: "report.docx"
|
||||
})
|
||||
).resolves.toBe(path.join(historyDirectory, "report.docx"));
|
||||
});
|
||||
|
||||
it("源目录和历史目录均不可用时回退到文档目录", async () => {
|
||||
const root = await createTemporaryDirectory();
|
||||
const documentsDirectory = path.join(root, "documents");
|
||||
await mkdir(documentsDirectory);
|
||||
|
||||
await expect(
|
||||
resolveDefaultSavePath({
|
||||
lastSaveDirectory: path.join(root, "missing"),
|
||||
documentsDirectory,
|
||||
suggestedFileName: "draft.md"
|
||||
})
|
||||
).resolves.toBe(path.join(documentsDirectory, "draft.md"));
|
||||
});
|
||||
|
||||
it("以原子 JSON 状态持久化并恢复最近目录", async () => {
|
||||
const root = await createTemporaryDirectory();
|
||||
const directory = path.join(root, "saved");
|
||||
const statePath = path.join(root, "state", "save-location.json");
|
||||
await mkdir(directory);
|
||||
|
||||
await saveSaveLocationState(statePath, directory);
|
||||
|
||||
await expect(loadSaveLocationState(statePath)).resolves.toEqual({
|
||||
version: 1,
|
||||
directory
|
||||
});
|
||||
expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
directory
|
||||
});
|
||||
});
|
||||
|
||||
it("跨 Store 实例恢复最近成功文件目录", async () => {
|
||||
const root = await createTemporaryDirectory();
|
||||
const savedDirectory = path.join(root, "saved");
|
||||
const documentsDirectory = path.join(root, "documents");
|
||||
const statePath = path.join(root, "save-location.json");
|
||||
await Promise.all([
|
||||
mkdir(savedDirectory),
|
||||
mkdir(documentsDirectory)
|
||||
]);
|
||||
const first = new SaveLocationStore(
|
||||
statePath,
|
||||
documentsDirectory
|
||||
);
|
||||
await first.initialize();
|
||||
await first.recordSuccessfulFile(
|
||||
path.join(savedDirectory, "first.pdf")
|
||||
);
|
||||
|
||||
const restored = new SaveLocationStore(
|
||||
statePath,
|
||||
documentsDirectory
|
||||
);
|
||||
await restored.initialize();
|
||||
|
||||
await expect(
|
||||
restored.resolveDefaultPath(undefined, "second.docx")
|
||||
).resolves.toBe(path.join(savedDirectory, "second.docx"));
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
# Markdown PDF 导出器进度
|
||||
|
||||
最后更新:2026-07-31
|
||||
最后更新:2026-08-01
|
||||
|
||||
## 1. 当前概况
|
||||
|
||||
@@ -353,6 +353,15 @@ SHA-256;交互模式仅在文件存在且哈希正确时显示默认勾选项
|
||||
文档右侧留白分别约为 30.38px、30.05px 和 30.45px;120% 与 400%
|
||||
均准确到达各自最大横向滚动值,快速和连续预览结构未改变。
|
||||
|
||||
Desktop 已统一 Markdown 保存/另存为、PDF 导出和 DOCX 导出的默认路径
|
||||
策略:有效源文件目录优先,其次使用最近一次成功写入文件的目录,最后
|
||||
回退到 Electron 返回的用户“文档”目录。最近目录只在目标文件实际写入
|
||||
成功后更新,普通保存已有 Markdown 也会更新;状态使用版本化 JSON、
|
||||
临时文件和原子替换写入应用数据目录,因此可跨窗口和应用重启复用。源
|
||||
目录或历史目录被删除、移动、变为普通文件或不可访问时会自动跳过,不
|
||||
阻断保存对话框。状态持久化失败不会把已经成功写入的用户文件误报为保存
|
||||
失败。该优化作为独立提交完成,不与 FP3-C Docker 部署改动混合。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
### 2.1 项目骨架
|
||||
|
||||
Reference in New Issue
Block a user