Files
MyReader-Web/src/platform/webVfs.ts
T
2026-05-16 23:24:55 +08:00

459 lines
15 KiB
TypeScript

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<string, FileSystemDirectoryHandle>()
private fileHandles = new Map<string, FileSystemFileHandle>()
private memoryFiles = new Map<string, File>()
private blobUrlByPath = new Map<string, string>()
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)
}
/** 仅通过 <input type="file"> 打开、无 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<string> {
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<void> {
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<string> {
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<string | null> {
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<string | null> {
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<string | null> {
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<string> {
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<string> {
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<void> {
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<FileSystemFileHandle> {
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<ListDirResult> {
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<FileSystemFileHandle | null> {
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<void> {
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<void>
}
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<FileSystemFileHandle | null> {
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,必须走 <input type="file"> 降级。
if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) return false
return true
}
export function getCachedImageBlobUrl(absPath: string): string | undefined {
return webVfs.getCachedBlobUrlPublic(normalizeAbsPath(absPath))
}