Skip to content

Commit 6fcb85a

Browse files
fileSystem: Class that works with browser or desktop file system (#3740)
- This uses the vscode.workspace.fs implementation Signed-off-by: Nikolas Komonen <[email protected]>
1 parent 3f8dd33 commit 6fcb85a

File tree

3 files changed

+365
-0
lines changed

3 files changed

+365
-0
lines changed

src/srcShared/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# srcShared
2+
3+
This folder must only contain code which can be used in the browser OR desktop.
4+
This is why we call it "shared".

src/srcShared/fs.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as vscode from 'vscode'
6+
7+
const fs = vscode.workspace.fs
8+
type Uri = vscode.Uri
9+
10+
/**
11+
* @warning Do not import this class specifically, instead import the instance {@link fsCommon}.
12+
*
13+
* This class contains file system methods that are "common", meaning
14+
* it can be used in the browser or desktop.
15+
*
16+
* Technical Details:
17+
* TODO: Verify the point below once I get this hooked up to the browser code.
18+
* - vscode.workspace.fs dynamically resolves the correct file system provider
19+
* to use. This means that in the browser it will attempt to use the File System
20+
* Access API.
21+
*
22+
*
23+
* MODIFYING THIS CLASS:
24+
* - All methods must work for both browser and desktop
25+
* - Do not use 'fs' or 'fs-extra' since they are not browser compatible.
26+
* If they have functionality that cannot be achieved with a
27+
* browser+desktop implementation, then create it in the module {@link TODO}
28+
*/
29+
export class FileSystemCommon {
30+
private constructor() {}
31+
static #instance: FileSystemCommon
32+
static get instance(): FileSystemCommon {
33+
return (this.#instance ??= new FileSystemCommon())
34+
}
35+
36+
async mkdir(path: Uri | string): Promise<void> {
37+
path = FileSystemCommon.getUri(path)
38+
return fs.createDirectory(path)
39+
}
40+
41+
async readFile(path: Uri | string): Promise<Uint8Array> {
42+
path = FileSystemCommon.getUri(path)
43+
return fs.readFile(path)
44+
}
45+
46+
async readFileAsString(path: Uri | string): Promise<string> {
47+
path = FileSystemCommon.getUri(path)
48+
return FileSystemCommon.arrayToString(await this.readFile(path))
49+
}
50+
51+
async appendFile(path: Uri | string, content: Uint8Array | string): Promise<void> {
52+
path = FileSystemCommon.getUri(path)
53+
54+
const currentContent: Uint8Array = (await this.fileExists(path)) ? await this.readFile(path) : new Uint8Array(0)
55+
const currentLength = currentContent.length
56+
57+
const newContent = FileSystemCommon.asArray(content)
58+
const newLength = newContent.length
59+
60+
const finalContent = new Uint8Array(currentLength + newLength)
61+
finalContent.set(currentContent)
62+
finalContent.set(newContent, currentLength)
63+
64+
return this.writeFile(path, finalContent)
65+
}
66+
67+
async fileExists(path: Uri | string): Promise<boolean> {
68+
path = FileSystemCommon.getUri(path)
69+
const stat = await this.stat(path)
70+
return stat === undefined ? false : stat.type === vscode.FileType.File
71+
}
72+
73+
async writeFile(path: Uri | string, data: string | Uint8Array): Promise<void> {
74+
path = FileSystemCommon.getUri(path)
75+
return fs.writeFile(path, FileSystemCommon.asArray(data))
76+
}
77+
78+
/**
79+
* The stat of the file, undefined if the file does not exist, otherwise an error is thrown.
80+
*/
81+
async stat(uri: vscode.Uri | string): Promise<vscode.FileStat | undefined> {
82+
const path = FileSystemCommon.getUri(uri)
83+
try {
84+
return await fs.stat(path)
85+
} catch (err) {
86+
if (err instanceof vscode.FileSystemError && err.code === 'FileNotFound') {
87+
return undefined
88+
}
89+
throw err
90+
}
91+
}
92+
93+
async delete(uri: vscode.Uri | string): Promise<void> {
94+
const path = FileSystemCommon.getUri(uri)
95+
return fs.delete(path, { recursive: true })
96+
}
97+
98+
async readdir(uri: vscode.Uri | string): Promise<[string, vscode.FileType][]> {
99+
const path = FileSystemCommon.getUri(uri)
100+
return await fs.readDirectory(path)
101+
}
102+
103+
// -------- private methods --------
104+
static readonly #decoder = new TextDecoder()
105+
static readonly #encoder = new TextEncoder()
106+
107+
private static arrayToString(array: Uint8Array) {
108+
return FileSystemCommon.#decoder.decode(array)
109+
}
110+
111+
private static stringToArray(string: string): Uint8Array {
112+
return FileSystemCommon.#encoder.encode(string)
113+
}
114+
115+
private static asArray(array: Uint8Array | string): Uint8Array {
116+
if (typeof array === 'string') {
117+
return FileSystemCommon.stringToArray(array)
118+
}
119+
return array
120+
}
121+
122+
/**
123+
* Retrieve the Uri of the file.
124+
*
125+
* @param path The file path for which to retrieve metadata.
126+
* @return The Uri about the file.
127+
*/
128+
private static getUri(path: string | vscode.Uri): vscode.Uri {
129+
if (path instanceof vscode.Uri) {
130+
return path
131+
}
132+
return vscode.Uri.file(path)
133+
}
134+
}

src/test/srcShared/fs.test.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as assert from 'assert'
7+
import * as vscode from 'vscode'
8+
import * as path from 'path'
9+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
10+
import { FakeExtensionContext } from '../fakeExtensionContext'
11+
import { FileSystemCommon } from '../../srcShared/fs'
12+
import * as os from 'os'
13+
import { isMinimumVersion } from '../../shared/vscode/env'
14+
15+
function isWin() {
16+
return os.platform() === 'win32'
17+
}
18+
19+
describe('FileSystem', function () {
20+
let fakeContext: vscode.ExtensionContext
21+
let fsCommon: FileSystemCommon
22+
23+
before(async function () {
24+
fakeContext = await FakeExtensionContext.create()
25+
fsCommon = FileSystemCommon.instance
26+
})
27+
28+
beforeEach(async function () {
29+
await makeTestRoot()
30+
})
31+
32+
afterEach(async function () {
33+
await deleteTestRoot()
34+
})
35+
36+
describe('readFileAsString()', function () {
37+
it('reads a file', async function () {
38+
const path = await makeFile('test.txt', 'hello world')
39+
const pathAsUri = vscode.Uri.file(path)
40+
41+
assert.strictEqual(await fsCommon.readFileAsString(path), 'hello world')
42+
assert.strictEqual(await fsCommon.readFileAsString(pathAsUri), 'hello world')
43+
})
44+
45+
it('throws when no permissions', async function () {
46+
if (isWin()) {
47+
console.log('Skipping since windows does not support mode permissions')
48+
return this.skip()
49+
}
50+
51+
const fileName = 'test.txt'
52+
const path = await makeFile(fileName, 'hello world', { mode: 0o000 })
53+
54+
await assert.rejects(fsCommon.readFileAsString(path), err => {
55+
assert(err instanceof vscode.FileSystemError)
56+
assert.strictEqual(err.code, 'NoPermissions')
57+
assert(err.message.includes(fileName))
58+
return true
59+
})
60+
})
61+
})
62+
63+
describe('writeFile()', function () {
64+
it('writes a file', async function () {
65+
const filePath = createTestPath('myFileName')
66+
await fsCommon.writeFile(filePath, 'MyContent')
67+
assert.strictEqual(readFileSync(filePath, 'utf-8'), 'MyContent')
68+
})
69+
70+
it('writes a file with encoded text', async function () {
71+
const filePath = createTestPath('myFileName')
72+
const text = 'hello'
73+
const content = new TextEncoder().encode(text)
74+
75+
await fsCommon.writeFile(filePath, content)
76+
77+
assert.strictEqual(readFileSync(filePath, 'utf-8'), text)
78+
})
79+
80+
it('throws when existing file + no permission', async function () {
81+
if (isWin()) {
82+
console.log('Skipping since windows does not support mode permissions')
83+
return this.skip()
84+
}
85+
if (isMinimumVersion()) {
86+
console.log('Skipping since min version has different error message')
87+
return this.skip()
88+
}
89+
90+
const fileName = 'test.txt'
91+
const filePath = await makeFile(fileName, 'hello world', { mode: 0o000 })
92+
93+
await assert.rejects(fsCommon.writeFile(filePath, 'MyContent'), err => {
94+
assert(err instanceof vscode.FileSystemError)
95+
assert.strictEqual(err.name, 'EntryWriteLocked (FileSystemError) (FileSystemError)')
96+
assert(err.message.includes(fileName))
97+
return true
98+
})
99+
})
100+
})
101+
102+
describe('appendFile()', function () {
103+
it('appends to a file', async function () {
104+
const filePath = await makeFile('test.txt', 'LINE-1-TEXT')
105+
await fsCommon.appendFile(filePath, '\nLINE-2-TEXT')
106+
assert.strictEqual(readFileSync(filePath, 'utf-8'), 'LINE-1-TEXT\nLINE-2-TEXT')
107+
})
108+
109+
it('creates new file if it does not exist', async function () {
110+
const filePath = createTestPath('thisDoesNotExist.txt')
111+
await fsCommon.appendFile(filePath, 'i am nikolas')
112+
assert.strictEqual(readFileSync(filePath, 'utf-8'), 'i am nikolas')
113+
})
114+
})
115+
116+
describe('fileExists()', function () {
117+
it('returns true for an existing file', async function () {
118+
const filePath = await makeFile('test.txt')
119+
const existantFile = await fsCommon.fileExists(filePath)
120+
assert.strictEqual(existantFile, true)
121+
})
122+
123+
it('returns false for a non-existant file', async function () {
124+
const nonExistantFile = await fsCommon.fileExists(createTestPath('thisDoesNotExist.txt'))
125+
assert.strictEqual(nonExistantFile, false)
126+
})
127+
})
128+
129+
describe('mkdir()', function () {
130+
const paths = ['a', 'a/b', 'a/b/c', 'a/b/c/d/']
131+
132+
paths.forEach(async function (p) {
133+
it(`creates path: '${p}'`, async function () {
134+
const dirPath = createTestPath(p)
135+
await fsCommon.mkdir(dirPath)
136+
assert.ok(existsSync(dirPath))
137+
})
138+
})
139+
})
140+
141+
describe('readdir()', function () {
142+
it('lists files in a directory', async function () {
143+
makeFile('a.txt')
144+
makeFile('b.txt')
145+
makeFile('c.txt')
146+
mkdirSync(createTestPath('dirA'))
147+
mkdirSync(createTestPath('dirB'))
148+
mkdirSync(createTestPath('dirC'))
149+
150+
const files = await fsCommon.readdir(testRootPath())
151+
assert.deepStrictEqual(
152+
sorted(files),
153+
sorted([
154+
['a.txt', vscode.FileType.File],
155+
['b.txt', vscode.FileType.File],
156+
['c.txt', vscode.FileType.File],
157+
['dirA', vscode.FileType.Directory],
158+
['dirB', vscode.FileType.Directory],
159+
['dirC', vscode.FileType.Directory],
160+
])
161+
)
162+
})
163+
164+
it('empty list if no files in directory', async function () {
165+
const files = await fsCommon.readdir(testRootPath())
166+
assert.deepStrictEqual(files, [])
167+
})
168+
169+
function sorted(i: [string, vscode.FileType][]) {
170+
return i.sort((a, b) => a[0].localeCompare(b[0]))
171+
}
172+
})
173+
174+
describe('delete()', function () {
175+
it('deletes a file', async function () {
176+
const filePath = await makeFile('test.txt', 'hello world')
177+
await fsCommon.delete(filePath)
178+
assert.ok(!existsSync(filePath))
179+
})
180+
181+
it('deletes a directory', async function () {
182+
const dirPath = createTestPath('dirToDelete')
183+
mkdirSync(dirPath)
184+
185+
await fsCommon.delete(dirPath)
186+
187+
assert.ok(!existsSync(dirPath))
188+
})
189+
})
190+
191+
describe('stat()', function () {
192+
it('gets stat of a file', async function () {
193+
const filePath = await makeFile('test.txt', 'hello world')
194+
const stat = await fsCommon.stat(filePath)
195+
assert.ok(stat)
196+
assert.strictEqual(stat.type, vscode.FileType.File)
197+
})
198+
199+
it('return undefined if no file exists', async function () {
200+
const filePath = createTestPath('thisDoesNotExist.txt')
201+
const stat = await fsCommon.stat(filePath)
202+
assert.strictEqual(stat, undefined)
203+
})
204+
})
205+
206+
async function makeFile(relativePath: string, content?: string, options?: { mode?: number }): Promise<string> {
207+
const filePath = path.join(testRootPath(), relativePath)
208+
writeFileSync(filePath, content ?? '', { mode: options?.mode })
209+
return filePath
210+
}
211+
212+
function createTestPath(relativePath: string): string {
213+
return path.join(testRootPath(), relativePath)
214+
}
215+
216+
function testRootPath() {
217+
return path.join(fakeContext.globalStorageUri.fsPath, 'testDir')
218+
}
219+
220+
async function makeTestRoot() {
221+
return mkdirSync(testRootPath())
222+
}
223+
224+
async function deleteTestRoot() {
225+
return rmSync(testRootPath(), { recursive: true })
226+
}
227+
})

0 commit comments

Comments
 (0)