Skip to content

Commit

Permalink
feat: Enable Chrome local file browsing
Browse files Browse the repository at this point in the history
EXPERIMENTAL!

Chrome (and only Chrome!) supports an experimental
"File System Access API" that enables direct reading of
user files, after the user explicitly grants access to
a folder and its contents.

https://wicg.github.io/file-system-access/

This is fully supported in Chrome (no flags necessary)
-- but it will likely never make it into any other browsers.

So, let's support it if the user is on Chrome.
  • Loading branch information
billyc committed Mar 4, 2022
1 parent 6f602a3 commit 5bb3bed
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 12 deletions.
8 changes: 8 additions & 0 deletions src/Globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface FileSystemConfig {
skipList?: string[]
dashboardFolder?: string
hidden?: boolean
handle?: FileSystemAPIHandle
}

export interface VisualizationPlugin {
Expand All @@ -111,6 +112,7 @@ export interface VisualizationPlugin {
export interface DirectoryEntry {
files: string[]
dirs: string[]
handles: { [name: string]: FileSystemAPIHandle }
}

export enum ColorScheme {
Expand Down Expand Up @@ -202,6 +204,12 @@ export interface RunYaml {
}[]
}

export interface FileSystemAPIHandle {
name: string
values: any // returns { kind: string; name: string; getFile: any }[]
getFile: any
}

export const LIGHT_MODE: ColorSet = {
text: '#000',
background: '#ccccc4',
Expand Down
14 changes: 13 additions & 1 deletion src/fileSystemConfig.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { FileSystemConfig } from '@/Globals'
import { FileSystemConfig, FileSystemAPIHandle } from '@/Globals'

// The URL contains the websiteLiveHost, calculated at runtime
const loc = window.location
const webLiveHostname = loc.hostname
const websiteLiveHost = `${loc.protocol}//${webLiveHostname}`

export function addLocalFilesystem(handle: FileSystemAPIHandle) {
const system: FileSystemConfig = {
name: handle.name,
slug: 'fs',
description: 'Local folder: ' + handle.name,
handle: handle,
baseURL: '',
}

fileSystems.unshift(system)
}

const fileSystems: FileSystemConfig[] = [
{
name: webLiveHostname + ' live folders',
Expand Down
123 changes: 113 additions & 10 deletions src/js/HTTPFileSystem.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import micromatch from 'micromatch'
import { DirectoryEntry, FileSystemConfig, YamlConfigs } from '@/Globals'
import { DirectoryEntry, FileSystemAPIHandle, FileSystemConfig, YamlConfigs } from '@/Globals'

const YAML_FOLDER = 'simwrapper'

Expand All @@ -12,10 +12,12 @@ class SVNFileSystem {
private baseUrl: string
private urlId: string
private needsAuth: boolean
private fsHandle: FileSystemAPIHandle | null

constructor(project: FileSystemConfig) {
this.urlId = project.slug
this.needsAuth = !!project.needPassword
this.fsHandle = project.handle || null

this.baseUrl = project.baseURL
if (!project.baseURL.endsWith('/')) this.baseUrl += '/'
Expand Down Expand Up @@ -45,6 +47,14 @@ class SVNFileSystem {
}

private async _getFileResponse(scaryPath: string): Promise<Response> {
if (this.fsHandle) {
return this._getFileFromChromeFileSystem(scaryPath)
} else {
return this._getFileFetchResponse(scaryPath)
}
}

private async _getFileFetchResponse(scaryPath: string): Promise<Response> {
const path = this.cleanURL(scaryPath)

const headers: any = {}
Expand All @@ -66,6 +76,44 @@ class SVNFileSystem {
return response
}

private async _getFileFromChromeFileSystem(scaryPath: string): Promise<Response> {
// Chrome File System Access API doesn't handle nested paths, annoying.
// We need to first fetch the directory to get the file handle, and then
// get the file contents.

let path = scaryPath.replace(/^0-9a-zA-Z_\-\/:+/i, '')
path = path.replaceAll('//', '/')
path = new URL(`http://local/${path}`).href
path = path.substring(13)

const slash = path.lastIndexOf('/')
const folder = path.substring(0, slash)
const filename = path.substring(slash + 1)

const dirContents = await this.getDirectory(folder)
const fileHandle = dirContents.handles[filename]

const file = (await fileHandle.getFile()) as any

file.json = () => {
return new Promise(async (resolve, reject) => {
const text = await file.text()
const json = JSON.parse(text)
resolve(json)
})
}

file.blob = () => {
return new Promise(async (resolve, reject) => {
resolve(file)
})
}

return new Promise((resolve, reject) => {
resolve(file)
})
}

async getFileText(scaryPath: string): Promise<string> {
// This can throw lots of errors; we are not going to catch them
// here so the code further up can deal with errors properly.
Expand Down Expand Up @@ -101,15 +149,70 @@ class SVNFileSystem {

// Use cached version if we have it
const cachedEntry = CACHE[this.urlId][stillScaryPath]
if (cachedEntry) {
return cachedEntry
}
if (cachedEntry) return cachedEntry

// Generate and cache the listing
const dirEntry = this.fsHandle
? await this.getDirectoryFromHandle(stillScaryPath)
: await this.getDirectoryFromURL(stillScaryPath)

CACHE[this.urlId][stillScaryPath] = dirEntry
return dirEntry
}

async getDirectoryFromHandle(stillScaryPath: string) {
// File System API has no concept of nested paths, which of course
// is how every filesystem from the past 60 years is actually laid out.
// Caching each level should lessen the pain of this weird workaround.

// Bounce thru each level until we reach this one.
// (Do this top down in order instead of recursive.)

// I want: /data/project/folder
// get / and find data
// get /data and find project
// get project and find folder
// get folder --> that's our answer

const contents: DirectoryEntry = { files: [], dirs: [], handles: {} }
if (!this.fsHandle) return contents

let parts = stillScaryPath.split('/').filter(p => !!p) // split and remove blanks

let currentDir = this.fsHandle as any

// iterate thru the tree, top-down:
if (parts.length) {
for (const subfolder of parts) {
console.log('searching for:', subfolder)
let found = false
for await (let [name, handle] of currentDir) {
if (name === subfolder) {
currentDir = handle
found = true
break
}
}
if (!found) throw Error('Could not find ' + subfolder)
}
}

// haven't crashed yet? Get the listing details!
for await (let entry of currentDir.values()) {
if (contents.handles) contents.handles[entry.name] = entry
if (entry.kind === 'file') {
contents.files.push(entry.name)
} else {
contents.dirs.push(entry.name)
}
}
return contents
}

async getDirectoryFromURL(stillScaryPath: string) {
const response = await this._getFileResponse(stillScaryPath).then()
const htmlListing = await response.text()
const dirEntry = this.buildListFromHtml(htmlListing)
CACHE[this.urlId][stillScaryPath] = dirEntry

return dirEntry
}

Expand Down Expand Up @@ -183,7 +286,7 @@ class SVNFileSystem {
if (data.indexOf('<ul>') > -1) return this.buildListFromSVN(data)
if (data.indexOf('<table>') > -1) return this.buildListFromApache24(data)

return { dirs: [], files: [] }
return { dirs: [], files: [], handles: {} }
}

private buildListFromSimpleWebServer(data: string): DirectoryEntry {
Expand All @@ -204,7 +307,7 @@ class SVNFileSystem {
if (name.endsWith('/')) dirs.push(name.substring(0, name.length - 1))
else files.push(name)
}
return { dirs, files }
return { dirs, files, handles: {} }
}

private buildListFromSVN(data: string): DirectoryEntry {
Expand All @@ -229,7 +332,7 @@ class SVNFileSystem {
if (name.endsWith('/')) dirs.push(name.substring(0, name.length - 1))
else files.push(name)
}
return { dirs, files }
return { dirs, files, handles: {} }
}

private buildListFromApache24(data: string): DirectoryEntry {
Expand Down Expand Up @@ -259,7 +362,7 @@ class SVNFileSystem {
if (name.endsWith('/')) dirs.push(name.substring(0, name.length - 1))
else files.push(name)
}
return { dirs, files }
return { dirs, files, handles: {} }
}
}

Expand Down
29 changes: 28 additions & 1 deletion src/views/SplashPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
file-system-projects.gap(@navigate="onNavigate")
hr

.is-chrome(v-if="isChrome")
h2 Chrome: Browse local files directly
button.button(@click="showChromeDirectory") Select folder...
hr

h2: b {{ $t('more-info') }}
info-bottom.splash-readme

Expand All @@ -30,6 +35,9 @@
</template>

<script lang="ts">
// Typescript doesn't know the Chrome File System API
declare const window: any
const i18n = {
messages: {
en: {
Expand All @@ -47,8 +55,8 @@ import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import globalStore from '@/store'
import FileSystemProjects from '@/components/FileSystemProjects.vue'
import InfoBottom from '@/assets/info-bottom.md'
import { addLocalFilesystem } from '@/fileSystemConfig'
@Component({
i18n,
Expand All @@ -74,6 +82,25 @@ class MyComponent extends Vue {
this.$emit('navigate', event)
}
// Only Chrome supports the FileSystemAPI
private get isChrome() {
return !!window.showDirectoryPicker
}
private async showChromeDirectory() {
try {
const FileSystemDirectoryHandle = window.showDirectoryPicker()
const dir = await FileSystemDirectoryHandle
// add our new local filesystem as root 'fs'
addLocalFilesystem(dir)
const BASE = import.meta.env.BASE_URL
this.$router.push(`${BASE}fs/`)
} catch (e) {
// shrug
}
}
// private readme = readme
// private readmeBottom = bottom
}
Expand Down

0 comments on commit 5bb3bed

Please sign in to comment.