import { randomId } from '../randomId' import type { DirEntry, ListDirResult } from '../types/ipc-types' import { dirnamePath, fileUrlToPath, isAbsDiskPath, joinAbsPath, normalizeAbsPath, pathBasename, pathsEqual, resolvePathFromBaseFile } from '../pathLite' const MD_EXT = new Set(['.md', '.markdown', '.mdown', '.mkd']) const IMAGE_EXT = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif' ]) function isMarkdownFile(name: string): boolean { const lower = name.toLowerCase() const dot = lower.lastIndexOf('.') if (dot < 0) return false return MD_EXT.has(lower.slice(dot)) } function isImageFile(name: string): boolean { const lower = name.toLowerCase() const dot = lower.lastIndexOf('.') if (dot < 0) return false return IMAGE_EXT.has(lower.slice(dot)) } function parentPath(p: string): string { const n = normalizeAbsPath(p).replace(/\/+$/, '') const i = n.lastIndexOf('/') if (i <= 0) return '/' const d = n.slice(0, i) if (/^[A-Za-z]:$/.test(d)) return `${d}/` return d || '/' } function baseName(p: string): string { const n = p.replace(/\\/g, '/').replace(/\/+$/, '') const i = n.lastIndexOf('/') return i >= 0 ? n.slice(i + 1) : n } type DirEntries = FileSystemDirectoryHandle & { entries: () => AsyncIterableIterator<[string, FileSystemHandle]> } async function* iterateDir( dir: FileSystemDirectoryHandle ): AsyncGenerator<[string, FileSystemHandle]> { const d = dir as DirEntries for await (const entry of d.entries()) { yield entry } } /** 浏览器端:File System Access 句柄 + 本机绝对路径(用户确认)映射。 */ class WebVfs { private dirHandles = new Map() private fileHandles = new Map() private memoryFiles = new Map() private blobUrlByPath = new Map() private defaultDir = '' getDefaultDirectory(): string { return this.defaultDir } setDefaultDirectory(p: string): void { this.defaultDir = normalizeAbsPath(p) } hasPath(path: string): boolean { const n = normalizeAbsPath(path) return this.fileHandles.has(n) || this.memoryFiles.has(n) || this.dirHandles.has(n) } /** 仅通过 打开、无 FileHandle,无法写回用户磁盘原文件。 */ isMemoryOnlyPath(path: string): boolean { const n = normalizeAbsPath(path) if (this.fileHandles.has(n)) return false return this.memoryFiles.has(n) } getCachedBlobUrlPublic(absPath: string): string | undefined { return this.getCachedBlobUrl(absPath) } listKnownImagePaths(): string[] { return Array.from(this.blobUrlByPath.keys()) } async registerDirectory(root: FileSystemDirectoryHandle, absRoot: string): Promise { const rootPath = normalizeAbsPath(absRoot) this.dirHandles.set(rootPath, root) this.defaultDir = rootPath await this.indexDirectory(root, rootPath, true) return rootPath } private async indexDirectory( dir: FileSystemDirectoryHandle, absPath: string, deep: boolean ): Promise { for await (const [name, handle] of iterateDir(dir)) { if (name.startsWith('.')) continue const full = joinAbsPath(absPath, name) if (handle.kind === 'directory') { const sub = handle as FileSystemDirectoryHandle this.dirHandles.set(full, sub) if (deep) await this.indexDirectory(sub, full, true) } else if (handle.kind === 'file') { const fh = handle as FileSystemFileHandle if (isMarkdownFile(name)) { this.fileHandles.set(full, fh) } if (isImageFile(name)) { void this.cacheBlobUrl(full, fh) } } } } private async cacheBlobUrl(absPath: string, handle: FileSystemFileHandle): Promise { const key = normalizeAbsPath(absPath) const prev = this.blobUrlByPath.get(key) if (prev) return prev const file = await handle.getFile() const url = URL.createObjectURL(file) this.blobUrlByPath.set(key, url) return url } /** 将图片 src(含 file://)解析为可显示的 blob URL。 */ async resolveImageToBlobUrl(src: string, baseFilePath: string | null): Promise { const s = src.trim() if (!s) return null if (/^(blob:|data:|https?:)/i.test(s)) return s const base = baseFilePath?.trim() ? normalizeAbsPath(baseFilePath) : null const absPath = this.resolveImageAbsPath(s, base) if (!absPath) return null const hit = await this.ensureBlobUrl(absPath) if (hit) return hit if (base) { const baseDir = dirnamePath(base) const imgDir = dirnamePath(absPath) if (pathsEqual(baseDir, imgDir)) { const sibling = joinAbsPath(baseDir, pathBasename(absPath)) if (!pathsEqual(sibling, absPath)) { const alt = await this.ensureBlobUrl(sibling) if (alt) return alt } } } return this.ensureBlobUrlByBasename(pathBasename(absPath)) } resolveImageAbsPath(src: string, baseFilePath: string | null): string | null { const s = src.trim() if (/^file:/i.test(s)) return normalizeAbsPath(fileUrlToPath(s)) if (isAbsDiskPath(s)) return normalizeAbsPath(s) if (baseFilePath) return normalizeAbsPath(resolvePathFromBaseFile(baseFilePath, s)) return null } private lookupFileHandle( absPath: string ): { key: string; handle: FileSystemFileHandle } | null { const key = normalizeAbsPath(absPath) const direct = this.fileHandles.get(key) if (direct) return { key, handle: direct } for (const [k, h] of this.fileHandles) { if (pathsEqual(k, key)) return { key: k, handle: h } } return null } private lookupImageByBasename(fileName: string): { key: string; handle: FileSystemFileHandle } | null { const want = fileName.toLowerCase() let found: { key: string; handle: FileSystemFileHandle } | null = null for (const [k, h] of this.fileHandles) { if (!isImageFile(pathBasename(k))) continue if (pathBasename(k).toLowerCase() !== want) continue if (!found) found = { key: k, handle: h } } return found } private async ensureBlobUrl(absPath: string): Promise { const key = normalizeAbsPath(absPath) const cached = this.getCachedBlobUrl(key) if (cached) return cached const located = this.lookupFileHandle(key) let handle = located?.handle let storeKey = located?.key ?? key if (!handle) { handle = (await this.ensureFileHandle(key)) ?? undefined storeKey = key } if (!handle) return null return this.cacheBlobUrl(storeKey, handle) } private async ensureBlobUrlByBasename(fileName: string): Promise { const located = this.lookupImageByBasename(fileName) if (!located) return null return this.ensureBlobUrl(located.key) } private getCachedBlobUrl(absPath: string): string | undefined { const key = normalizeAbsPath(absPath) const direct = this.blobUrlByPath.get(key) if (direct) return direct for (const [k, url] of this.blobUrlByPath) { if (pathsEqual(k, key)) return url } return undefined } async registerOpenFile(handle: FileSystemFileHandle, absPath: string): Promise { const normalized = normalizeAbsPath(absPath) this.fileHandles.set(normalized, handle) this.memoryFiles.delete(normalized) this.defaultDir = dirnamePath(normalized) return normalized } /** 为「仅打开文件」生成稳定虚拟路径(无需用户填写磁盘绝对路径)。 */ virtualPathForPickedFile(fileName: string): string { const id = randomId() const safe = fileName.replace(/[/\\<>|:*?"\u0000-\u001f]/g, '_').trim() || 'note.md' return normalizeAbsPath(`/__web__/open/${id}/${safe}`) } registerMemoryFile(absPath: string, file: File): string { const normalized = normalizeAbsPath(absPath) this.memoryFiles.set(normalized, file) return normalized } async readFile(path: string): Promise { const normalized = normalizeAbsPath(path) const mem = this.memoryFiles.get(normalized) if (mem) return mem.text() let h = this.fileHandles.get(normalized) if (!h) { h = (await this.ensureFileHandle(normalized)) ?? undefined } if (!h) throw new Error(`文件不存在或未授权:${normalized}`) const file = await h.getFile() return file.text() } async writeFile(path: string, content: string): Promise { const normalized = normalizeAbsPath(path) const mem = this.memoryFiles.get(normalized) if (mem) { this.memoryFiles.set(normalized, new File([content], mem.name, { type: 'text/markdown' })) return } let h = this.fileHandles.get(normalized) if (!h) { h = await this.createFileAtParent(normalized) } const w = await h.createWritable() await w.write(content) await w.close() } private async createFileAtParent(path: string): Promise { const parent = parentPath(path) const name = baseName(path) const dir = this.dirHandles.get(parent) if (!dir) throw new Error(`无法写入:父目录未打开 (${parent})`) const h = await dir.getFileHandle(name, { create: true }) this.fileHandles.set(path, h) return h } async listDirectory(dirPath: string): Promise { const normalized = normalizeAbsPath(dirPath) const dir = this.dirHandles.get(normalized) if (!dir) { return { entries: [], error: '未授权访问该目录(Web 版请使用工具栏「打开」选择文件)。' } } try { const entries: DirEntry[] = [] for await (const [name, handle] of iterateDir(dir)) { if (name.startsWith('.')) continue const full = joinAbsPath(normalized, name) if (handle.kind === 'directory') { this.dirHandles.set(full, handle as FileSystemDirectoryHandle) entries.push({ name, path: full, kind: 'dir' }) } else if (handle.kind === 'file' && isMarkdownFile(name)) { this.fileHandles.set(full, handle as FileSystemFileHandle) entries.push({ name, path: full, kind: 'file' }) } } entries.sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) }) return { entries } } catch (e) { const msg = e instanceof Error ? e.message : String(e) return { entries: [], error: msg } } } parentDirectory(dirPath: string): string { const n = normalizeAbsPath(dirPath) if (!n) return n if (isAbsDiskPath(n)) { const p = parentPath(n) return p } return parentPath(n) } private findWorkspaceRoot(absPath: string): { rootPath: string; dir: FileSystemDirectoryHandle } | null { const normalized = normalizeAbsPath(absPath) let best = '' let bestDir: FileSystemDirectoryHandle | null = null for (const [rp, dir] of this.dirHandles) { const root = normalizeAbsPath(rp) if (normalized === root || normalized.startsWith(`${root}/`)) { if (root.length > best.length) { best = root bestDir = dir } } } return bestDir ? { rootPath: best, dir: bestDir } : null } async ensureFileHandle(absPath: string): Promise { const normalized = normalizeAbsPath(absPath) const direct = this.fileHandles.get(normalized) if (direct) return direct const ws = this.findWorkspaceRoot(normalized) if (!ws) return null const relParts = normalized.slice(ws.rootPath.length).split('/').filter(Boolean) if (relParts.length === 0) return null try { let cur = ws.dir let curPath = ws.rootPath for (let i = 0; i < relParts.length - 1; i++) { const part = relParts[i]! cur = await cur.getDirectoryHandle(part) curPath = joinAbsPath(curPath, part) this.dirHandles.set(curPath, cur) } const fileName = relParts[relParts.length - 1]! const fh = await cur.getFileHandle(fileName) this.fileHandles.set(normalized, fh) if (isImageFile(fileName)) void this.cacheBlobUrl(normalized, fh) return fh } catch { return null } } async deleteFile(path: string): Promise { const normalized = normalizeAbsPath(path) const parent = parentPath(normalized) const name = baseName(normalized) const dir = this.dirHandles.get(parent) if (!dir) throw new Error('无法删除:父目录未找到') await dir.removeEntry(name) this.fileHandles.delete(normalized) } async renamePath(oldPath: string, newName: string): Promise<{ newPath: string }> { const oldNorm = normalizeAbsPath(oldPath) const parent = parentPath(oldNorm) const dir = this.dirHandles.get(parent) if (!dir) throw new Error('无法重命名:父目录未找到') const newPath = joinAbsPath(parent, newName.trim()) const handle = this.fileHandles.get(oldNorm) ?? this.dirHandles.get(oldNorm) if (!handle) throw new Error('无法重命名:条目未找到') const mover = dir as FileSystemDirectoryHandle & { move?: (item: FileSystemHandle, name: string) => Promise } if (typeof mover.move !== 'function') { throw new Error('当前浏览器不支持重命名,请在资源管理器中手动操作') } await mover.move(handle, newName.trim()) if (this.fileHandles.has(oldNorm)) { this.fileHandles.delete(oldNorm) if (handle.kind === 'file') this.fileHandles.set(newPath, handle as FileSystemFileHandle) } else if (this.dirHandles.has(oldNorm)) { this.dirHandles.delete(oldNorm) if (handle.kind === 'directory') this.dirHandles.set(newPath, handle as FileSystemDirectoryHandle) } return { newPath } } async pickSaveHandle( suggestedName?: string ): Promise { if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) { return null } if ('showSaveFilePicker' in window) { try { return await window.showSaveFilePicker!({ suggestedName: suggestedName || '未命名.md', types: [ { description: 'Markdown', accept: { 'text/markdown': ['.md', '.markdown'] } } ] }) } catch { return null } } return null } } export const webVfs = new WebVfs() export function supportsFileSystemAccess(): boolean { if (typeof window.showOpenFilePicker !== 'function') return false // Chromium 上 API 可能「存在」但在非安全上下文调用会抛 SecurityError,必须走 降级。 if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) return false return true } export function getCachedImageBlobUrl(absPath: string): string | undefined { return webVfs.getCachedBlobUrlPublic(normalizeAbsPath(absPath)) }