459 lines
15 KiB
TypeScript
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))
|
|
}
|