@componentor/fs 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +742 -0
- package/dist/index.d.ts +544 -0
- package/dist/index.js +2551 -0
- package/dist/index.js.map +1 -0
- package/dist/opfs-hybrid.d.ts +198 -0
- package/dist/opfs-hybrid.js +2552 -0
- package/dist/opfs-hybrid.js.map +1 -0
- package/dist/opfs-worker-proxy.d.ts +224 -0
- package/dist/opfs-worker-proxy.js +274 -0
- package/dist/opfs-worker-proxy.js.map +1 -0
- package/dist/opfs-worker.js +2732 -0
- package/dist/opfs-worker.js.map +1 -0
- package/package.json +66 -0
- package/src/constants.ts +52 -0
- package/src/errors.ts +88 -0
- package/src/file-handle.ts +100 -0
- package/src/global.d.ts +57 -0
- package/src/handle-manager.ts +250 -0
- package/src/index.ts +1404 -0
- package/src/opfs-hybrid.ts +265 -0
- package/src/opfs-worker-proxy.ts +374 -0
- package/src/opfs-worker.ts +253 -0
- package/src/packed-storage.ts +426 -0
- package/src/path-utils.ts +97 -0
- package/src/streams.ts +109 -0
- package/src/symlink-manager.ts +329 -0
- package/src/types.ts +285 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Path normalization cache - LRU-style with max size
|
|
2
|
+
const normalizeCache = new Map<string, string>()
|
|
3
|
+
const CACHE_MAX_SIZE = 1000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a path, handling . and .. components
|
|
7
|
+
* Results are cached for performance on repeated calls
|
|
8
|
+
*/
|
|
9
|
+
export function normalize(path: string | undefined | null): string {
|
|
10
|
+
if (path === undefined || path === null) {
|
|
11
|
+
throw new TypeError('Path cannot be undefined or null')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (typeof path !== 'string') {
|
|
15
|
+
throw new TypeError(`Expected string path, got ${typeof path}`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (path === '') {
|
|
19
|
+
return '/'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check cache first
|
|
23
|
+
const cached = normalizeCache.get(path)
|
|
24
|
+
if (cached !== undefined) {
|
|
25
|
+
return cached
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parts = path.split('/')
|
|
29
|
+
const stack: string[] = []
|
|
30
|
+
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
if (part === '' || part === '.') {
|
|
33
|
+
continue
|
|
34
|
+
} else if (part === '..') {
|
|
35
|
+
if (stack.length > 0) stack.pop()
|
|
36
|
+
} else {
|
|
37
|
+
stack.push(part)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = '/' + stack.join('/')
|
|
42
|
+
|
|
43
|
+
// Cache the result (simple LRU: clear when full)
|
|
44
|
+
if (normalizeCache.size >= CACHE_MAX_SIZE) {
|
|
45
|
+
// Delete oldest entries (first 25%)
|
|
46
|
+
const deleteCount = CACHE_MAX_SIZE / 4
|
|
47
|
+
let count = 0
|
|
48
|
+
for (const key of normalizeCache.keys()) {
|
|
49
|
+
if (count++ >= deleteCount) break
|
|
50
|
+
normalizeCache.delete(key)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
normalizeCache.set(path, result)
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get parent directory path
|
|
60
|
+
*/
|
|
61
|
+
export function dirname(path: string): string {
|
|
62
|
+
const normalized = normalize(path)
|
|
63
|
+
const parts = normalized.split('/').filter(Boolean)
|
|
64
|
+
if (parts.length < 2) return '/'
|
|
65
|
+
return '/' + parts.slice(0, -1).join('/')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get base filename
|
|
70
|
+
*/
|
|
71
|
+
export function basename(path: string): string {
|
|
72
|
+
const normalized = normalize(path)
|
|
73
|
+
const parts = normalized.split('/').filter(Boolean)
|
|
74
|
+
return parts[parts.length - 1] || ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Join path segments
|
|
79
|
+
*/
|
|
80
|
+
export function join(...paths: string[]): string {
|
|
81
|
+
return normalize(paths.join('/'))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if path is root
|
|
86
|
+
*/
|
|
87
|
+
export function isRoot(path: string): boolean {
|
|
88
|
+
const normalized = normalize(path)
|
|
89
|
+
return normalized === '/' || normalized === ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get path segments (excluding empty)
|
|
94
|
+
*/
|
|
95
|
+
export function segments(path: string): string[] {
|
|
96
|
+
return normalize(path).split('/').filter(Boolean)
|
|
97
|
+
}
|
package/src/streams.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ReadStreamOptions, WriteStreamOptions } from './types.js'
|
|
2
|
+
|
|
3
|
+
export interface ReadStreamContext {
|
|
4
|
+
readFile(path: string): Promise<Uint8Array>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface WriteStreamContext {
|
|
8
|
+
readFile(path: string): Promise<Uint8Array>
|
|
9
|
+
writeFile(path: string, data: Uint8Array): Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a ReadableStream for reading file contents
|
|
14
|
+
*/
|
|
15
|
+
export function createReadStream(
|
|
16
|
+
path: string,
|
|
17
|
+
options: ReadStreamOptions,
|
|
18
|
+
context: ReadStreamContext
|
|
19
|
+
): ReadableStream<Uint8Array> {
|
|
20
|
+
const { start = 0, end = Infinity, highWaterMark = 64 * 1024 } = options
|
|
21
|
+
let position = start
|
|
22
|
+
let closed = false
|
|
23
|
+
let cachedData: Uint8Array | null = null
|
|
24
|
+
|
|
25
|
+
return new ReadableStream({
|
|
26
|
+
async pull(controller) {
|
|
27
|
+
if (closed) {
|
|
28
|
+
controller.close()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// Cache file data on first read - avoid re-reading on every pull
|
|
34
|
+
if (cachedData === null) {
|
|
35
|
+
cachedData = await context.readFile(path)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const endPos = Math.min(end, cachedData.length)
|
|
39
|
+
const chunk = cachedData.subarray(position, Math.min(position + highWaterMark, endPos))
|
|
40
|
+
|
|
41
|
+
if (chunk.length === 0 || position >= endPos) {
|
|
42
|
+
controller.close()
|
|
43
|
+
closed = true
|
|
44
|
+
cachedData = null // Release memory
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
position += chunk.length
|
|
49
|
+
controller.enqueue(chunk)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
controller.error(err)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
cancel() {
|
|
55
|
+
closed = true
|
|
56
|
+
cachedData = null // Release memory
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a WritableStream for writing file contents
|
|
63
|
+
*/
|
|
64
|
+
export function createWriteStream(
|
|
65
|
+
path: string,
|
|
66
|
+
options: WriteStreamOptions,
|
|
67
|
+
context: WriteStreamContext
|
|
68
|
+
): WritableStream<Uint8Array> {
|
|
69
|
+
const { flags = 'w', start = 0 } = options
|
|
70
|
+
const chunks: Array<{ data: Uint8Array; position: number }> = []
|
|
71
|
+
let position = start
|
|
72
|
+
|
|
73
|
+
return new WritableStream({
|
|
74
|
+
async write(chunk) {
|
|
75
|
+
chunks.push({ data: chunk, position })
|
|
76
|
+
position += chunk.length
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async close() {
|
|
80
|
+
// Combine all chunks
|
|
81
|
+
let existingData = new Uint8Array(0)
|
|
82
|
+
|
|
83
|
+
if (!flags.includes('w')) {
|
|
84
|
+
try {
|
|
85
|
+
existingData = await context.readFile(path)
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if ((e as { code?: string }).code !== 'ENOENT') throw e
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let maxSize = existingData.length
|
|
92
|
+
for (const { data, position } of chunks) {
|
|
93
|
+
maxSize = Math.max(maxSize, position + data.length)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const finalData = new Uint8Array(maxSize)
|
|
97
|
+
|
|
98
|
+
if (!flags.includes('w')) {
|
|
99
|
+
finalData.set(existingData, 0)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const { data, position } of chunks) {
|
|
103
|
+
finalData.set(data, position)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await context.writeFile(path, finalData)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { SymlinkCache, SymlinkDefinition } from './types.js'
|
|
2
|
+
import type { HandleManager } from './handle-manager.js'
|
|
3
|
+
import { normalize } from './path-utils.js'
|
|
4
|
+
import { createELOOP, createEINVAL, createEEXIST } from './errors.js'
|
|
5
|
+
|
|
6
|
+
const SYMLINK_FILE = '/.opfs-symlinks.json'
|
|
7
|
+
const MAX_SYMLINK_DEPTH = 10
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Manages symbolic link emulation using a JSON metadata file
|
|
11
|
+
*/
|
|
12
|
+
export class SymlinkManager {
|
|
13
|
+
private cache: SymlinkCache | null = null
|
|
14
|
+
private cacheCount = 0 // Track count to avoid Object.keys() calls
|
|
15
|
+
private resolvedCache: Map<string, string> = new Map() // Cache resolved paths
|
|
16
|
+
private dirty = false
|
|
17
|
+
private handleManager: HandleManager
|
|
18
|
+
private useSync: boolean
|
|
19
|
+
private loadPromise: Promise<SymlinkCache> | null = null // Avoid multiple concurrent loads
|
|
20
|
+
private diskLoaded = false // Track if we've loaded from disk
|
|
21
|
+
|
|
22
|
+
constructor(handleManager: HandleManager, useSync: boolean) {
|
|
23
|
+
this.handleManager = handleManager
|
|
24
|
+
this.useSync = useSync
|
|
25
|
+
// Initialize with empty cache - most operations won't need symlinks
|
|
26
|
+
this.cache = {}
|
|
27
|
+
this.cacheCount = 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reset all symlink state (called when root directory is cleared)
|
|
32
|
+
*/
|
|
33
|
+
reset(): void {
|
|
34
|
+
this.cache = {}
|
|
35
|
+
this.cacheCount = 0
|
|
36
|
+
this.resolvedCache.clear()
|
|
37
|
+
this.dirty = false
|
|
38
|
+
this.loadPromise = null
|
|
39
|
+
this.diskLoaded = false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load symlinks from metadata file
|
|
44
|
+
* Uses loadPromise to avoid multiple concurrent disk reads
|
|
45
|
+
*/
|
|
46
|
+
async load(): Promise<SymlinkCache> {
|
|
47
|
+
// Fast path: if we've already loaded from disk, use cached data
|
|
48
|
+
if (this.diskLoaded) return this.cache!
|
|
49
|
+
|
|
50
|
+
// If there's already a load in progress, wait for it
|
|
51
|
+
if (this.loadPromise) return this.loadPromise
|
|
52
|
+
|
|
53
|
+
// Load from disk
|
|
54
|
+
this.loadPromise = this.loadFromDisk()
|
|
55
|
+
const result = await this.loadPromise
|
|
56
|
+
this.loadPromise = null
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Actually read from disk
|
|
62
|
+
*/
|
|
63
|
+
private async loadFromDisk(): Promise<SymlinkCache> {
|
|
64
|
+
try {
|
|
65
|
+
const { fileHandle } = await this.handleManager.getHandle(SYMLINK_FILE)
|
|
66
|
+
if (!fileHandle) {
|
|
67
|
+
// No symlink file exists - keep empty cache
|
|
68
|
+
this.diskLoaded = true
|
|
69
|
+
return this.cache!
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const file = await fileHandle.getFile()
|
|
73
|
+
const text = await file.text()
|
|
74
|
+
this.cache = JSON.parse(text)
|
|
75
|
+
this.cacheCount = Object.keys(this.cache).length
|
|
76
|
+
this.diskLoaded = true
|
|
77
|
+
} catch {
|
|
78
|
+
// Error reading - keep empty cache
|
|
79
|
+
if (!this.cache) {
|
|
80
|
+
this.cache = {}
|
|
81
|
+
this.cacheCount = 0
|
|
82
|
+
}
|
|
83
|
+
this.diskLoaded = true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return this.cache!
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Save symlinks to metadata file
|
|
91
|
+
*/
|
|
92
|
+
async save(): Promise<void> {
|
|
93
|
+
if (!this.cache) return
|
|
94
|
+
|
|
95
|
+
// Use compact JSON (no formatting) for better performance
|
|
96
|
+
const data = JSON.stringify(this.cache)
|
|
97
|
+
const { fileHandle } = await this.handleManager.getHandle(SYMLINK_FILE, { create: true })
|
|
98
|
+
|
|
99
|
+
if (!fileHandle) return
|
|
100
|
+
|
|
101
|
+
const buffer = new TextEncoder().encode(data)
|
|
102
|
+
|
|
103
|
+
if (this.useSync) {
|
|
104
|
+
const access = await fileHandle.createSyncAccessHandle()
|
|
105
|
+
access.truncate(0)
|
|
106
|
+
let written = 0
|
|
107
|
+
while (written < buffer.length) {
|
|
108
|
+
written += access.write(buffer.subarray(written), { at: written })
|
|
109
|
+
}
|
|
110
|
+
access.close()
|
|
111
|
+
} else {
|
|
112
|
+
const writable = await fileHandle.createWritable()
|
|
113
|
+
await writable.write(buffer)
|
|
114
|
+
await writable.close()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.dirty = false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Flush pending changes if dirty
|
|
122
|
+
*/
|
|
123
|
+
async flush(): Promise<void> {
|
|
124
|
+
if (this.dirty) {
|
|
125
|
+
await this.save()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a path through symlinks
|
|
131
|
+
* Fast synchronous path when cache is already loaded
|
|
132
|
+
* Uses resolved cache for O(1) repeated lookups
|
|
133
|
+
*
|
|
134
|
+
* OPTIMIZATION: If we haven't loaded from disk yet AND no symlinks have been
|
|
135
|
+
* created in this session, we skip the disk check entirely. This makes pure
|
|
136
|
+
* file operations (no symlinks) very fast.
|
|
137
|
+
*/
|
|
138
|
+
async resolve(path: string, maxDepth = MAX_SYMLINK_DEPTH): Promise<string> {
|
|
139
|
+
// Ultra-fast path: if no symlinks exist in memory, return immediately
|
|
140
|
+
// This covers both: (1) fresh session with no symlinks, (2) loaded from disk with no symlinks
|
|
141
|
+
if (this.cacheCount === 0) {
|
|
142
|
+
// If we've loaded from disk and it's empty, we're done
|
|
143
|
+
// If we haven't loaded from disk, assume no symlinks until a symlink op is called
|
|
144
|
+
return path
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// We have symlinks in memory - resolve them
|
|
148
|
+
// Check resolved cache first for instant lookup
|
|
149
|
+
const cached = this.resolvedCache.get(path)
|
|
150
|
+
if (cached !== undefined) {
|
|
151
|
+
return cached
|
|
152
|
+
}
|
|
153
|
+
return this.resolveSync(path, this.cache!, maxDepth)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Synchronous resolution helper - caches the result
|
|
158
|
+
*/
|
|
159
|
+
private resolveSync(path: string, symlinks: SymlinkCache, maxDepth: number): string {
|
|
160
|
+
let currentPath = path
|
|
161
|
+
let depth = 0
|
|
162
|
+
|
|
163
|
+
while (symlinks[currentPath] && depth < maxDepth) {
|
|
164
|
+
currentPath = symlinks[currentPath]
|
|
165
|
+
depth++
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (depth >= maxDepth) {
|
|
169
|
+
throw createELOOP(path)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Cache the resolved path if it was actually a symlink
|
|
173
|
+
if (currentPath !== path) {
|
|
174
|
+
this.resolvedCache.set(path, currentPath)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return currentPath
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Clear the resolved path cache (called when symlinks change)
|
|
182
|
+
*/
|
|
183
|
+
private clearResolvedCache(): void {
|
|
184
|
+
this.resolvedCache.clear()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if a path is a symlink
|
|
189
|
+
*/
|
|
190
|
+
async isSymlink(path: string): Promise<boolean> {
|
|
191
|
+
const symlinks = await this.load()
|
|
192
|
+
return !!symlinks[path]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get symlink target
|
|
197
|
+
*/
|
|
198
|
+
async readlink(path: string): Promise<string> {
|
|
199
|
+
const normalizedPath = normalize(path)
|
|
200
|
+
const symlinks = await this.load()
|
|
201
|
+
|
|
202
|
+
if (!symlinks[normalizedPath]) {
|
|
203
|
+
throw createEINVAL(path)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return symlinks[normalizedPath]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create a symlink
|
|
211
|
+
*/
|
|
212
|
+
async symlink(target: string, path: string, checkExists: () => Promise<void>): Promise<void> {
|
|
213
|
+
const normalizedPath = normalize(path)
|
|
214
|
+
const normalizedTarget = normalize(target)
|
|
215
|
+
|
|
216
|
+
const symlinks = await this.load()
|
|
217
|
+
|
|
218
|
+
if (symlinks[normalizedPath]) {
|
|
219
|
+
throw createEEXIST(normalizedPath)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await checkExists()
|
|
223
|
+
|
|
224
|
+
symlinks[normalizedPath] = normalizedTarget
|
|
225
|
+
this.cacheCount++
|
|
226
|
+
this.clearResolvedCache() // Invalidate resolved cache
|
|
227
|
+
this.dirty = true
|
|
228
|
+
await this.flush()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create multiple symlinks efficiently
|
|
233
|
+
*/
|
|
234
|
+
async symlinkBatch(
|
|
235
|
+
links: SymlinkDefinition[],
|
|
236
|
+
checkExists: (path: string) => Promise<void>
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const symlinks = await this.load()
|
|
239
|
+
|
|
240
|
+
// Prepare all normalized paths first
|
|
241
|
+
const normalizedLinks = links.map(({ target, path }) => ({
|
|
242
|
+
normalizedPath: normalize(path),
|
|
243
|
+
normalizedTarget: normalize(target)
|
|
244
|
+
}))
|
|
245
|
+
|
|
246
|
+
// Check for existing symlinks in memory (fast)
|
|
247
|
+
for (const { normalizedPath } of normalizedLinks) {
|
|
248
|
+
if (symlinks[normalizedPath]) {
|
|
249
|
+
throw createEEXIST(normalizedPath)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check filesystem existence in parallel (I/O bound)
|
|
254
|
+
await Promise.all(normalizedLinks.map(({ normalizedPath }) => checkExists(normalizedPath)))
|
|
255
|
+
|
|
256
|
+
// Add all symlinks at once
|
|
257
|
+
for (const { normalizedPath, normalizedTarget } of normalizedLinks) {
|
|
258
|
+
symlinks[normalizedPath] = normalizedTarget
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.cacheCount += links.length
|
|
262
|
+
this.clearResolvedCache() // Invalidate resolved cache
|
|
263
|
+
this.dirty = true
|
|
264
|
+
await this.flush()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Remove a symlink
|
|
269
|
+
*/
|
|
270
|
+
async unlink(path: string): Promise<boolean> {
|
|
271
|
+
const symlinks = await this.load()
|
|
272
|
+
|
|
273
|
+
if (symlinks[path]) {
|
|
274
|
+
delete symlinks[path]
|
|
275
|
+
this.cacheCount--
|
|
276
|
+
this.clearResolvedCache() // Invalidate resolved cache
|
|
277
|
+
this.dirty = true
|
|
278
|
+
await this.flush()
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Rename/move a symlink
|
|
287
|
+
*/
|
|
288
|
+
async rename(oldPath: string, newPath: string): Promise<boolean> {
|
|
289
|
+
const symlinks = await this.load()
|
|
290
|
+
|
|
291
|
+
if (symlinks[oldPath]) {
|
|
292
|
+
const target = symlinks[oldPath]
|
|
293
|
+
delete symlinks[oldPath]
|
|
294
|
+
symlinks[newPath] = target
|
|
295
|
+
this.clearResolvedCache() // Invalidate resolved cache
|
|
296
|
+
this.dirty = true
|
|
297
|
+
await this.flush()
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get all symlinks in a directory
|
|
306
|
+
*/
|
|
307
|
+
async getSymlinksInDir(dirPath: string): Promise<string[]> {
|
|
308
|
+
const symlinks = await this.load()
|
|
309
|
+
const result: string[] = []
|
|
310
|
+
|
|
311
|
+
for (const symlinkPath of Object.keys(symlinks)) {
|
|
312
|
+
const parts = symlinkPath.split('/').filter(Boolean)
|
|
313
|
+
const parentPath = '/' + parts.slice(0, -1).join('/')
|
|
314
|
+
|
|
315
|
+
if (parentPath === dirPath || (dirPath === '/' && parts.length === 1)) {
|
|
316
|
+
result.push(parts[parts.length - 1])
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return result
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check if path is the symlink metadata file
|
|
325
|
+
*/
|
|
326
|
+
isMetadataFile(name: string): boolean {
|
|
327
|
+
return name === SYMLINK_FILE.replace(/^\/+/, '')
|
|
328
|
+
}
|
|
329
|
+
}
|