feat(app): 支持设置 .md 默认打开方式(docs/32)

三条链路:①tauri.conf bundle.fileAssociations 注册 md/markdown,
新版安装包重装后出现在「打开方式」候选;②文件菜单「设为 .md
默认打开方式…」:Rust 命令经 reg.exe 写 HKCU 的 ProgID
NewMarkdown.md/OpenWithProgids/SupportedTypes(免管理员、指向
当前 exe),再打开系统默认应用设置让用户确认一次(Windows 不许
程序直接抢默认);③双击打开:单实例插件转发第二实例参数到待
打开队列+聚焦主窗口,冷启动读 env args,共用 md_files_from_args
过滤;前端 take_pending_open_files 取走逐个打开,只由 main 窗口
消费防子窗口抢文件。命令数 31→33 对账,参数过滤纯函数已抽 crate
实测,vitest 154+2、构建通过;Windows 构建与双击全流程待真机验。
This commit is contained in:
Muse
2026-11-15 13:57:31 +08:00
parent 492051cef0
commit c300b4c8db
10 changed files with 710 additions and 4 deletions

View File

@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
import { insertPlainText, insertLinkText } from './editor/paste'
import { isImeComposing } from './utils/ime'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal } from './components/Modals'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal, InfoModal } from './components/Modals'
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
@@ -29,7 +29,7 @@ import {
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, takePendingOpenFiles, registerFileAssociation, type GitStatusInfo,
} from './services/platform'
import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery'
@@ -708,11 +708,47 @@ export default function App() {
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
const startupFile = new URLSearchParams(window.location.search).get('file')
if (!cancelled && startupFile) void openPath(startupFile)
// 双击 .md 冷启动:文件路径经启动参数进 Rust 的待打开队列(docs/32)。
// 只由主窗口消费——子窗口(?file= 打开的)也在跑同一套启动流程,
// 不拦会把队列里的文件抢去自己窗口打开。
if (!cancelled && tauriMode) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
if (getCurrentWindow().label === 'main') {
const pending = await takePendingOpenFiles()
for (const p of pending) { if (!cancelled) await openPath(p) }
}
} catch { /* 队列不可用时不影响正常启动 */ }
}
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 第二个文件双击转来时(单实例转发):Rust 发事件,这里取队列打开(docs/32)
useEffect(() => {
if (!tauriMode) return
let unlisten: (() => void) | undefined
let cancelled = false
void (async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
if (getCurrentWindow().label !== 'main') return // 只由主窗口消费(理由同冷启动)
const { listen } = await import('@tauri-apps/api/event')
const un = await listen('pending-open-files', async () => {
try {
const pending = await takePendingOpenFiles()
for (const p of pending) await openPath(p)
} catch { /* 同上 */ }
})
if (cancelled) un()
else unlisten = un
} catch { /* 事件不可用时退化为仅冷启动打开 */ }
})()
return () => { cancelled = true; unlisten?.() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tauriMode])
useEffect(() => {
if (workspaceRoot) {
store.addWorkspace(workspaceRoot)
@@ -1090,6 +1126,8 @@ export default function App() {
/** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */
const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null)
const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null)
/** 文件关联注册后的引导文案(docs/32) */
const [assocMsg, setAssocMsg] = useState<string | null>(null)
const openEditorMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement
const inEditor = sourceMode
@@ -1586,6 +1624,8 @@ export default function App() {
{ divider: true },
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
{ label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) },
{ divider: true },
{ label: '设为 .md 默认打开方式…', disabled: !tauriMode, onClick: () => { void (async () => { try { setAssocMsg(await registerFileAssociation()) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } })() } },
],
},
{
@@ -2091,6 +2131,7 @@ export default function App() {
}}
/>
)}
{assocMsg && <InfoModal title="文件关联" message={assocMsg} onClose={() => setAssocMsg(null)} />}
{imageSizeTarget && (
<ImageSizeModal
current={imageSizeTarget.width}

View File

@@ -260,6 +260,16 @@ export function ImageLinkModal({ src, linkHref, onSubmit, onCancel }: {
)
}
export function InfoModal({ title, message, onClose }: { title: string; message: string; onClose: () => void }) {
return (
<Shell onCancel={onClose}>
<h3>{title}</h3>
<p>{message}</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>知道了</button></div>
</Shell>
)
}
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
return (
<Shell onCancel={onClose}>

View File

@@ -366,6 +366,16 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
return invoke<string>('s3_upload_image', { config, filePath, fileName })
}
/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .md 路径(docs/32)。 */
export async function takePendingOpenFiles(): Promise<string[]> {
return invoke<string[]>('take_pending_open_files')
}
/** 注册 .md/.markdown 文件关联并打开系统默认应用设置,返回引导文案(docs/32)。 */
export async function registerFileAssociation(): Promise<string> {
return invoke<string>('register_file_association')
}
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_base64', { config, base64, fileName })