import {CodeEditor} from '../CodeEditor';
import {CodeEditor} from '../CodeEditor';
import {FileTree} from './FileTree';
import {FileTree} from './FileTree';
import {NativeModules} from 'react-native';
import {fileSystem} from '../fileSystem';
import {fileSystem} from '../fileSystem';
import {useThemedStyles} from '../theme';
import {useThemedStyles} from '../theme';
const styles = useThemedStyles(createStyles);
const styles = useThemedStyles(createStyles);
const [source, setSource] = useState('// some comment\n');
const [source, setSource] = useState('// some comment\n');
const [currentFile, setCurrentFile] = useState(null);
const [currentFile, setCurrentFile] = useState(null);
const [root, setRoot] = useState(null);
// Which folder the server is serving is its state, not this component's —
// it survives a reload of the app, so it is read rather than assumed.
useEffect(() => {
fileSystem.getRoot().then(response => setRoot(response.root));
}, []);
const save = useCallback(
const save = useCallback(
async (contents = source) => {
async (contents = source) => {
[save],
[save],
);
);
const openFolder = useCallback(async () => {
const chosen = await NativeModules.FolderPicker.pick();
if (!chosen || chosen === root) {
return;
}
// Every path in flight is relative to the folder being left behind, so the
// buffer is written and released before the server moves. A debounced save
// landing after the swap would put the old file's contents at the same
// relative path inside the new folder.
await save();
setCurrentFile(null);
setSource('');
const response = await fileSystem.setRoot(chosen);
setRoot(response.root);
}, [root, save]);
// The buffer is written once it stops moving. Clearing the timer on every
// The buffer is written once it stops moving. Clearing the timer on every
// change is also what makes switching files safe: a write scheduled against
// change is also what makes switching files safe: a write scheduled against
// one path can never land on the next one.
// one path can never land on the next one.
return (
return (
<View style={styles.editor}>
<View style={styles.editor}>
<View style={styles.fileTree}>
<View style={styles.fileTree}>
<FileTree currentFile={currentFile} onOpenFile={openFile} onSave={save} />
{/* Keyed on the root so switching folders remounts the tree: every
FileNode holds its expanded children locally, and those point at
paths that no longer exist. */}
<FileTree
key={root}
root={root}
currentFile={currentFile}
onOpenFile={openFile}
onOpenFolder={openFolder}
/>
</View>
</View>
<View style={styles.codeEditorContainer}>
<View style={styles.codeEditorContainer}>
import {fileSystem} from '../fileSystem';
import {fileSystem} from '../fileSystem';
import {sortFiles} from './sortFiles';
import {sortFiles} from './sortFiles';
export function FileTree({currentFile, onOpenFile, onSave}) {
export function FileTree({root, currentFile, onOpenFile, onOpenFolder}) {
const styles = useThemedStyles(createStyles);
const styles = useThemedStyles(createStyles);
const [files, setFiles] = useState([]);
const [files, setFiles] = useState([]);
useEffect(() => {
useEffect(() => {
if (!root) {
return;
}
async function fetchFiles() {
async function fetchFiles() {
const unsortedFiles = await fileSystem.listFiles('');
const unsortedFiles = await fileSystem.listFiles('');
const sortedFiles = sortFiles(unsortedFiles.contents ?? []);
const sortedFiles = sortFiles(unsortedFiles.contents ?? []);
setFiles(sortedFiles);
setFiles(sortedFiles);
}
}
fetchFiles();
fetchFiles();
}, []);
}, [root]);
function handleFileSelect(file) {
function handleFileSelect(file) {
if (file.isDirectory) {
if (file.isDirectory) {
return (
return (
<ScrollView style={styles.editor}>
<ScrollView style={styles.editor}>
<View style={styles.header}>
<Text style={styles.root} numberOfLines={1}>
{root ? folderName(root) : 'Connecting…'}
</Text>
<Text style={styles.openFolder} onPress={onOpenFolder}>Open…</Text>
</View>
{files.map(file => (
{files.map(file => (
<View key={file.name}>
<View key={file.name}>
{!file.isDirectory && (
{!file.isDirectory && (
);
);
}
}
/** The last segment of the root, which is what the folder is called. */
function folderName(root) {
return root.split('/').filter(Boolean).pop() ?? root;
}
const createStyles = colors => StyleSheet.create({
const createStyles = colors => StyleSheet.create({
editor: {
editor: {
...glass(colors, {tint: 0.25}),
...glass(colors, {tint: 0.25}),
borderRadius: 16,
borderRadius: 16,
flex: 1,
flex: 1,
},
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
marginBottom: 12,
},
root: {
color: colors.onSurface,
fontWeight: '600',
flexShrink: 1,
},
openFolder: {
color: colors.primary,
},
});
});
writeFile(path, contents) {
writeFile(path, contents) {
const fullUrl = `/file?path=${encodeURIComponent(path)}`;
const fullUrl = `/file?path=${encodeURIComponent(path)}`;
const options = {
method: "PUT",
headers: {
...this.defaultHeaders,
return this.sendRequest("PUT", fullUrl, { contents });
},
},
body: JSON.stringify({ contents }),
};
return fetch(BASE_URL + fullUrl, options)
.then(async (response) => {
if (!response.ok) {
throw new Error(`PUT ${fullUrl} failed with ${response.status}`);
}
if (response.headers.get("content-type")?.includes("application/json")) { return response.json(); }
return response.text();
});
/** The folder every other path in this module is relative to. */
getRoot() {
return this.makeRequest("/root");
},
/** Points the file server at `root`, an absolute path on this machine. */
setRoot(root) {
return this.sendRequest("PUT", "/root", { root });
},
},
makeRequest(url, path = null, headers = {}) {
makeRequest(url, path = null, headers = {}) {
const fullUrl = path ? `${url}?path=${encodeURIComponent(path)}` : url;
const fullUrl = path ? `${url}?path=${encodeURIComponent(path)}` : url;
return this.sendRequest("GET", fullUrl, null, headers);
},
sendRequest(method, fullUrl, body = null, headers = {}) {
const options = {
const options = {
method: "GET",
method,
headers: {
headers: {
...this.defaultHeaders,
...this.defaultHeaders,
...headers,
...headers,
},
},
};
};
if (body) {
options.body = JSON.stringify(body);
}
// React Native has no page to resolve relative URLs against, so every
// React Native has no page to resolve relative URLs against, so every
// request is prefixed with the API host.
// request is prefixed with the API host.
return fetch(BASE_URL + fullUrl, options)
return fetch(BASE_URL + fullUrl, options)
.then(async (response) => {
.then(async (response) => {
if (!response.ok) {
if (!response.ok) {
throw new Error(`GET ${fullUrl} failed with ${response.status}`);
throw new Error(`${method} ${fullUrl} failed with ${response.status}`);
}
}
if (response.headers.get("content-type")?.includes("application/json")) { return response.json(); }
if (response.headers.get("content-type")?.includes("application/json")) { return response.json(); }
return response.text();
return response.text();
#import <React/RCTBridgeModule.h>
// Registers the Swift module with React Native, the same wiring as
// VisualEffectBackgroundManager.m minus the view. Without this file
// `NativeModules.FolderPicker` is undefined at runtime.
@interface RCT_EXTERN_MODULE (FolderPicker, NSObject)
RCT_EXTERN_METHOD(pick
: (RCTPromiseResolveBlock)resolve rejecter
: (RCTPromiseRejectBlock)reject)
@end
import AppKit
/// The macOS folder chooser, exposed to JS.
///
/// The editor's root used to be a constant in `scripts/fileserver.mjs`, edited
/// by hand to move between projects. Choosing it at runtime needs a real
/// NSOpenPanel: React Native has no file dialog on macOS, and the web platform
/// deliberately hands back an opaque directory handle rather than the path the
/// file server has to be given.
@objc(FolderPicker)
final class FolderPicker: NSObject {
/// AppKit is main-thread only, panel included.
@objc static func requiresMainQueueSetup() -> Bool {
true
}
/// Resolves the chosen absolute path, or `nil` when the panel is
/// cancelled. Cancelling is an ordinary outcome rather than a failure —
/// rejecting would throw into JS every time the user changes their mind.
@objc func pick(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.message = "Choose the folder to edit"
panel.prompt = "Open"
// `begin` and not `runModal`: a modal run blocks the main thread,
// which is the thread the bridge answers this call on.
panel.begin { response in
guard response == .OK, let url = panel.url else {
return resolve(nil)
}
resolve(url.path)
}
}
}
}
514201582437B4B40078DB4F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 514201572437B4B40078DB4F /* main.m */; };
514201582437B4B40078DB4F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 514201572437B4B40078DB4F /* main.m */; };
79FCEEF07207716C27E9DF30 /* VisualEffectBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8FD93628865810AA46B7E5 /* VisualEffectBackground.swift */; };
79FCEEF07207716C27E9DF30 /* VisualEffectBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8FD93628865810AA46B7E5 /* VisualEffectBackground.swift */; };
990171D020C0C38C9607F68E /* VisualEffectBackgroundManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A039A6F4B75D1F3775D06AD /* VisualEffectBackgroundManager.m */; };
990171D020C0C38C9607F68E /* VisualEffectBackgroundManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A039A6F4B75D1F3775D06AD /* VisualEffectBackgroundManager.m */; };
AA11BB22CC33DD44EE550002 /* FolderPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550001 /* FolderPicker.swift */; };
AA11BB22CC33DD44EE550004 /* FolderPicker.m in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550003 /* FolderPicker.m */; };
9C56D442527F143851522FDA /* libPods-AllInOne-macOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 51D7A7A288CDBC43DC190B35 /* libPods-AllInOne-macOS.a */; };
9C56D442527F143851522FDA /* libPods-AllInOne-macOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 51D7A7A288CDBC43DC190B35 /* libPods-AllInOne-macOS.a */; };
A2D3CC7B5795A9A08615F348 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FA8B636D4C8DC87B5ACBABB6 /* PrivacyInfo.xcprivacy */; };
A2D3CC7B5795A9A08615F348 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FA8B636D4C8DC87B5ACBABB6 /* PrivacyInfo.xcprivacy */; };
DFCA6E5640BC03B37BC6DC91 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FA8B636D4C8DC87B5ACBABB6 /* PrivacyInfo.xcprivacy */; };
DFCA6E5640BC03B37BC6DC91 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FA8B636D4C8DC87B5ACBABB6 /* PrivacyInfo.xcprivacy */; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FA8B636D4C8DC87B5ACBABB6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
FA8B636D4C8DC87B5ACBABB6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
FE8FD93628865810AA46B7E5 /* VisualEffectBackground.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VisualEffectBackground.swift; sourceTree = "<group>"; };
FE8FD93628865810AA46B7E5 /* VisualEffectBackground.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VisualEffectBackground.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE550001 /* FolderPicker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FolderPicker.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE550003 /* FolderPicker.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = FolderPicker.m; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
514201592437B4B40078DB4F /* AllInOne.entitlements */,
514201592437B4B40078DB4F /* AllInOne.entitlements */,
FE8FD93628865810AA46B7E5 /* VisualEffectBackground.swift */,
FE8FD93628865810AA46B7E5 /* VisualEffectBackground.swift */,
0A039A6F4B75D1F3775D06AD /* VisualEffectBackgroundManager.m */,
0A039A6F4B75D1F3775D06AD /* VisualEffectBackgroundManager.m */,
AA11BB22CC33DD44EE550001 /* FolderPicker.swift */,
AA11BB22CC33DD44EE550003 /* FolderPicker.m */,
71E70C37A4A60F7215DC7DD6 /* AllInOne-Bridging-Header.h */,
71E70C37A4A60F7215DC7DD6 /* AllInOne-Bridging-Header.h */,
);
);
path = "AllInOne-macOS";
path = "AllInOne-macOS";
5142014D2437B4B30078DB4F /* AppDelegate.mm in Sources */,
5142014D2437B4B30078DB4F /* AppDelegate.mm in Sources */,
79FCEEF07207716C27E9DF30 /* VisualEffectBackground.swift in Sources */,
79FCEEF07207716C27E9DF30 /* VisualEffectBackground.swift in Sources */,
990171D020C0C38C9607F68E /* VisualEffectBackgroundManager.m in Sources */,
990171D020C0C38C9607F68E /* VisualEffectBackgroundManager.m in Sources */,
AA11BB22CC33DD44EE550002 /* FolderPicker.swift in Sources */,
AA11BB22CC33DD44EE550004 /* FolderPicker.m in Sources */,
);
);
runOnlyForDeploymentPostprocessing = 0;
runOnlyForDeploymentPostprocessing = 0;
};
};
import fs from 'node:fs/promises';
import fs from 'node:fs/promises';
import http from 'node:http';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import path from 'node:path';
/**
* The folder every path in a request is relative to.
*
* Chosen at runtime rather than compiled in: the app asks for it over `/root`
* and replaces it over `PUT /root` once the user picks a folder in the native
* panel. The argument and the environment variable are for launching the
* server straight at a project without going through the UI.
*/
let root = path.resolve(process.argv[2] ?? process.env.ROOT ?? os.homedir());
const server = http.createServer(async (request, response) => {
const server = http.createServer(async (request, response) => {
const { method, url } = request;
const { method, url } = request;
const { pathname, searchParams } = new URL(url, 'http://localhost');
const { pathname, searchParams } = new URL(url, 'http://localhost');
const relative = searchParams.get('path');
const relative = searchParams.get('path');
// const ROOT = "/Users/lucas/Desktop/webinargeek/app";
const ROOT = "/Users/lucas/Desktop/personal/code/all-in-one"
if (pathname === '/root') {
if (method === 'GET') {
return sendJson(response, 200, { root });
}
if (method === 'PUT') {
return setRoot(request, response);
}
}
if (method == "PUT" && pathname === "/file") {
if (method == "PUT" && pathname === "/file") {
return new Promise((resolve) => {
let body = '';
request.on('data', chunk => {
body += chunk.toString();
});
request.on('end', async () => {
const wantedPath = relative || '';
const wantedPath = relative || '';
const absolute = path.resolve(ROOT, wantedPath);
const absolute = path.resolve(root, wantedPath);
// convert the body from json string to an object
let body;
try {
try {
const parsedBody = JSON.parse(body);
body = parsedBody.contents;
body = (await readBody(request)).contents;
} catch (error) {
} catch (error) {
console.error(`Failed to parse request body: ${body}; error: ${error}`);
console.error(`Failed to parse request body; error: ${error}`);
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid request body' }));
return resolve();
return sendJson(response, 400, { error: 'invalid request body' });
}
}
if (!absolute.startsWith(ROOT)) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return resolve();
if (!isInsideRoot(absolute)) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
}
try {
try {
await fs.writeFile(absolute, body, 'utf8');
await fs.writeFile(absolute, body, 'utf8');
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ path: wantedPath }));
return sendJson(response, 200, { path: wantedPath });
} catch (error) {
} catch (error) {
console.error(`Failed to write file: ${absolute}; error: ${error}`);
console.error(`Failed to write file: ${absolute}; error: ${error}`);
response.writeHead(500, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'failed to write file' }));
return sendJson(response, 500, { error: 'failed to write file' });
}
}
resolve();
});
});
}
}
if (method !== 'GET' || (pathname !== '/file' && pathname !== '/files')) {
if (method !== 'GET' || (pathname !== '/file' && pathname !== '/files')) {
response.writeHead(404, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'not found' }));
return;
return sendJson(response, 404, { error: 'not found' });
}
}
if (!relative && pathname === '/file') {
if (!relative && pathname === '/file') {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'missing path parameter' }));
return;
return sendJson(response, 400, { error: 'missing path parameter' });
}
}
const wantedPath = relative || '';
const wantedPath = relative || '';
if (wantedPath.includes('..')) {
if (wantedPath.includes('..')) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return;
return sendJson(response, 400, { error: 'invalid path parameter' });
}
}
if (wantedPath.startsWith('/') && pathname === '/file') {
if (wantedPath.startsWith('/') && pathname === '/file') {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return;
return sendJson(response, 400, { error: 'invalid path parameter' });
}
}
const absolute = path.resolve(ROOT, wantedPath);
const absolute = path.resolve(root, wantedPath);
if (!absolute.startsWith(ROOT)) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return;
if (!isInsideRoot(absolute)) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
}
if (pathname === '/file') {
if (pathname === '/file') {
return getFileContents(absolute, response, wantedPath);
return getFileContents(absolute, response, wantedPath);
} else if (pathname === '/files') {
} else if (pathname === '/files') {
let path = wantedPath || '';
return getDirectoryContents(absolute, response, wantedPath);
return getDirectoryContents(absolute, response, wantedPath);
}
}
});
});
server.listen(4001, '127.0.0.1', () => {
server.listen(4001, '127.0.0.1', () => {
console.log('listening on http://127.0.0.1:4001');
console.log(`listening on http://127.0.0.1:4001, serving ${root}`);
});
});
/**
* `absolute.startsWith(root)` is not a path test — with a root of
* `/code/app` it also accepts `/code/app-secrets`. It mattered little while
* the root was a constant; now that the user picks it, the containment check
* has to be a real one.
*/
function isInsideRoot(absolute) {
const inside = path.relative(root, absolute);
return inside === '' || (!inside.startsWith('..') && !path.isAbsolute(inside));
}
async function setRoot(request, response) {
let wanted;
try {
wanted = (await readBody(request)).root;
} catch (error) {
console.error(`Failed to parse request body; error: ${error}`);
return sendJson(response, 400, { error: 'invalid request body' });
}
if (typeof wanted !== 'string' || !path.isAbsolute(wanted)) {
return sendJson(response, 400, { error: 'root must be an absolute path' });
}
// `resolve` also drops the trailing slash AppKit puts on directory paths,
// which would otherwise show up in every `path.relative` comparison.
const absolute = path.resolve(wanted);
try {
const stats = await fs.stat(absolute);
if (!stats.isDirectory()) {
return sendJson(response, 400, { error: 'root is not a directory' });
}
} catch (error) {
console.error(`Failed to open root: ${absolute}; error: ${error}`);
return sendJson(response, 400, { error: 'no such directory' });
}
root = absolute;
console.log(`serving ${root}`);
return sendJson(response, 200, { root });
}
function readBody(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', chunk => {
body += chunk.toString();
});
request.on('error', reject);
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (error) {
reject(error);
}
});
});
}
function sendJson(response, status, payload) {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
async function getFileContents(absolutePath, response, wantedPath) {
async function getFileContents(absolutePath, response, wantedPath) {
try {
try {
const contents = await fs.readFile(absolutePath, 'utf8');
const contents = await fs.readFile(absolutePath, 'utf8');
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ path: wantedPath, contents }));
sendJson(response, 200, { path: wantedPath, contents });
} catch (error) {
} catch (error) {
console.error(`Failed to read file: ${absolutePath}; error: ${error}`);
console.error(`Failed to read file: ${absolutePath}; error: ${error}`);
response.writeHead(404, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'not found' }));
sendJson(response, 404, { error: 'not found' });
}
}
}
}
isDirectory: entry.isDirectory(),
isDirectory: entry.isDirectory(),
fullPath: path.join(wantedPath, entry.name),
fullPath: path.join(wantedPath, entry.name),
}));
}));
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ path: wantedPath, contents }));
sendJson(response, 200, { path: wantedPath, contents });
} catch (error) {
} catch (error) {
console.error(`Failed to read directory: ${absolutePath}; error: ${error}`);
console.error(`Failed to read directory: ${absolutePath}; error: ${error}`);
response.writeHead(404, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'not found' }));
sendJson(response, 404, { error: 'not found' });
}
}
}
}