### The bundled server
### The bundled server
The Code page reads and writes files over `http://127.0.0.1:4001` and runs its shell
The Code page reads and writes files over `http://127.0.0.1:4001` — and creates,
over `ws://127.0.0.1:4001/terminal`. In development the Procfile starts that server; a
copies, renames and deletes them there too, which is what the explorer's own
editing runs on — and runs its shell over `ws://127.0.0.1:4001/terminal`. In development the Procfile starts that server; a
copy of the app on someone else's Mac has no Procfile, so the release build carries the
copy of the app on someone else's Mac has no Procfile, so the release build carries the
server *and* a Node runtime to execute it inside the bundle.
server *and* a Node runtime to execute it inside the bundle.
return () => clearTimeout(timer);
return () => clearTimeout(timer);
}, [currentFile, source, save]);
}, [currentFile, source, save]);
// Renaming and deleting in the tree move the ground the open tabs are
// standing on. A folder takes its contents with it, so both handlers match
// the path itself *and* everything under it rather than just the entry.
const onEntryRenamed = useCallback(
(fromPath, toPath) => {
const moved = filePath => {
if (filePath === fromPath) {
return toPath;
}
return filePath.startsWith(`${fromPath}/`) ? toPath + filePath.slice(fromPath.length) : filePath;
};
setOpenedFiles(current => current.map(moved));
if (currentFile) {
setCurrentFile(moved(currentFile));
}
},
[currentFile],
);
const onEntryRemoved = useCallback(
removedPath => {
const isGone = filePath => filePath === removedPath || filePath.startsWith(`${removedPath}/`);
setOpenedFiles(current => current.filter(filePath => !isGone(filePath)));
// Clearing the buffer as well as the tab is what stops the auto-save
// below from writing the file straight back out again.
if (currentFile && isGone(currentFile)) {
setCurrentFile(null);
setSource('');
}
},
[currentFile],
);
// Cmd+P reaches us three ways depending on where focus sits: through the
// Cmd+P reaches us three ways depending on where focus sits: through the
// `keyDownEvents` chain when it is on a native view, through Monaco's own
// `keyDownEvents` chain when it is on a native view, through Monaco's own
// binding when it is in the editor's WebView, and through the document on
// binding when it is in the editor's WebView, and through the document on
<View ref={page} focusable enableFocusRing={false} style={styles.editor} onKeyDown={onKeyDown} keyDownEvents={KEY_DOWN_EVENTS}>
<View ref={page} focusable enableFocusRing={false} style={styles.editor} onKeyDown={onKeyDown} keyDownEvents={KEY_DOWN_EVENTS}>
{activeSidebarItem == "files" && (
{activeSidebarItem == "files" && (
<View style={styles.fileTree}>
<View style={styles.fileTree}>
<FileTree currentFile={currentFile} onOpenFile={openFile} onSave={save} projectRoot={projectRoot} setProjectRoot={setProjectRoot} openedFiles={openedFiles} setOpenedFiles={setOpenedFiles} />
<FileTree currentFile={currentFile} onOpenFile={openFile} projectRoot={projectRoot} setProjectRoot={setProjectRoot} onEntryRenamed={onEntryRenamed} onEntryRemoved={onEntryRemoved} />
</View>
</View>
)}
)}
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {glass, useThemedStyles} from '../theme';
import {useEffect, useRef} from 'react';
import {useFileTree} from './fileTreeContext';
const KEY_DOWN_EVENTS = [{key: 'Escape'}, {key: 'Enter'}];
/**
* Deleting here is a real delete — the server has no Finder trash to hand the
* entry to — so the dialog says as much rather than implying an undo that does
* not exist. Escape cancels, Return confirms.
*/
export function ConfirmDelete() {
const styles = useThemedStyles(createStyles);
const {pendingDelete, confirmDelete, cancelDelete} = useFileTree();
const panel = useRef(null);
useEffect(() => {
panel.current?.focus?.();
}, []);
function onKeyDown(event) {
const {key} = event.nativeEvent ?? event;
if (key === 'Escape') {
cancelDelete();
}
if (key === 'Enter') {
confirmDelete();
}
}
return (
<View style={styles.overlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={cancelDelete} />
<View ref={panel} focusable enableFocusRing={false} style={styles.panel} onKeyDown={onKeyDown} keyDownEvents={KEY_DOWN_EVENTS}>
<Text style={styles.title}>Delete {pendingDelete.name}?</Text>
<Text style={styles.body}>
{pendingDelete.isDirectory
? 'The folder and everything in it is deleted from disk. This cannot be undone.'
: 'The file is deleted from disk. This cannot be undone.'}
</Text>
<View style={styles.buttons}>
<Pressable style={styles.cancel} onPress={cancelDelete}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable style={styles.confirm} onPress={confirmDelete}>
<Text style={styles.confirmText}>Delete</Text>
</Pressable>
</View>
</View>
</View>
);
}
const createStyles = colors => StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
// The tree is a narrow column, and this sits inside it, so the panel takes
// the width it is given rather than asking for a dialog's worth of it.
panel: {
alignSelf: 'stretch',
maxWidth: 420,
marginHorizontal: 8,
padding: 16,
borderRadius: 16,
gap: 8,
...glass(colors, {tint: 0.95}),
backgroundColor: colors.surfaceAt2,
shadowColor: colors.shadow,
shadowOffset: {width: 0, height: 20},
shadowOpacity: 0.4,
shadowRadius: 60,
},
title: {
color: colors.onSurface,
fontSize: 16,
fontWeight: '600',
},
body: {
color: colors.onSurfaceVariant,
fontSize: 13,
},
buttons: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: 8,
marginTop: 12,
},
cancel: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 90,
...glass(colors, {tint: 0.5}),
},
cancelText: {
color: colors.onSurface,
},
confirm: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 90,
backgroundColor: colors.error,
},
confirmText: {
color: colors.onError,
fontWeight: '600',
},
});
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {DraftRow, TreeRow} from './TreeRow';
import {FileIcon} from '../icons/FileIcon';
import {Icon} from '../icons';
import {fileSystem} from '../fileSystem';
import {sortFiles} from './sortFiles';
import {useState} from 'react';
import {useThemedStyles} from '../theme';
import {View} from 'react-native';
import {useFileTree} from './fileTreeContext';
export function FileNode({projectRoot, folder, onOpenFile, itemsDeep}) {
const styles = useThemedStyles(createStyles);
const [children, setChildren] = useState([]);
const [isOpen, setIsOpen] = useState(false);
/**
* One entry, plus whatever is under it. A folder's contents come from the
* shared tree rather than from state of its own, so a row redrawn after an
* edit is the same code path as a row drawn for the first time.
*/
export function FileNode({entry, depth}) {
const {expanded, draft, toggleDirectory, onOpenFile} = useFileTree();
function handleFileSelect(file) {
if (file.isDirectory) {
if (isOpen) {
setIsOpen(false);
return setChildren([]);
if (draft?.mode === 'rename' && draft.targetPath === entry.fullPath) {
return <DraftRow depth={depth} />;
}
}
setIsOpen(true);
return openDirectory();
}
const isOpen = Boolean(expanded[entry.fullPath]);
return onOpenFile(file.fullPath);
function open() {
if (entry.isDirectory) {
return toggleDirectory(entry.fullPath);
}
}
async function openDirectory() {
const unsortedItems = await fileSystem.listFiles(projectRoot, folder.fullPath);
const sortedFiles = sortFiles(unsortedItems.contents ?? []);
setChildren(sortedFiles);
setIsOpen(true);
return onOpenFile?.(entry.fullPath);
}
}
return (
return (
<View style={[styles.editor, {marginLeft: (16 * (itemsDeep ?? 0))}]}>
<Pressable style={styles.row} onPress={() => handleFileSelect(folder)}>
{folder.isDirectory ? (
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={16} color="black" />
) : (
<View style={styles.chevronSpacer} />
)}
<View>
<TreeRow entry={entry} depth={depth} isOpen={isOpen} onPress={open} />
{entry.isDirectory && isOpen && <DirectoryContents directoryPath={entry.fullPath} depth={depth + 1} />}
</View>
);
}
/**
* The contents of one directory, root included — `''` is the project itself,
* which is why the top level needs no special case here or in `FileTree`.
*
* A new entry is named in place, at the top of the folder it is being made in,
* so the field is somewhere predictable instead of wherever the finished name
* would eventually sort to.
*/
export function DirectoryContents({directoryPath, depth}) {
const {entries, draft} = useFileTree();
<FileIcon name={folder.name} isDirectory={folder.isDirectory} isOpen={isOpen} />
const isNamingHere = draft?.mode === 'create' && draft.parentPath === directoryPath;
<Text>{folder.name}</Text>
</Pressable>
return (
<>
{isNamingHere && <DraftRow depth={depth} />}
{children?.map(subFile => (
<FileNode
projectRoot={projectRoot}
key={subFile.fullPath}
folder={subFile}
onOpenFile={onOpenFile}
itemsDeep={(itemsDeep ?? 0) + 1}
/>
{(entries[directoryPath] ?? []).map(entry => (
<FileNode key={entry.fullPath} entry={entry} depth={depth} />
))}
))}
</View>
</>
);
);
}
}
const createStyles = colors => StyleSheet.create({
editor: {
marginBottom: 4,
},
row: {
flexDirection: 'row',
gap: 4,
alignItems: 'center',
},
});
import {FileTreeProvider, useFileTree} from './fileTreeContext';
import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import {NativeModules, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import {RowMenu, rowMenuSize} from './RowMenu';
import {glass, useThemedStyles} from '../theme';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {useEffect, useState} from 'react';
import {useEffect, useRef, useState} from 'react';
import {FileIcon} from '../icons/FileIcon';
import {FileNode} from './FileNode';
import {NativeModules} from 'react-native';
import {fileSystem} from '../fileSystem';
import {sortFiles} from './sortFiles';
import {ConfirmDelete} from './ConfirmDelete';
import {DirectoryContents} from './FileNode';
import {Icon} from '../icons';
import {isSecondaryClick} from './TreeRow';
import {parentOf} from './fileTreeContext';
export function FileTree({currentFile, onOpenFile, onSave, projectRoot, setProjectRoot, openedFiles, setOpenedFiles}) {
export function FileTree({currentFile, onOpenFile, projectRoot, setProjectRoot, onEntryRenamed, onEntryRemoved}) {
return (
<FileTreeProvider
projectRoot={projectRoot}
currentFile={currentFile}
onOpenFile={onOpenFile}
onEntryRenamed={onEntryRenamed}
onEntryRemoved={onEntryRemoved}>
<FileTreeBody projectRoot={projectRoot} setProjectRoot={setProjectRoot} />
</FileTreeProvider>
);
}
function clamp(value, lowest, highest) {
return Math.min(Math.max(value, lowest), Math.max(lowest, highest));
}
// Claimed so macOS stops handling these itself — Return and Delete both beep
// otherwise. The events reach JS either way; this only silences the system's
// own answer to them.
const KEY_DOWN_EVENTS = [
{key: 'Enter'},
{key: 'Backspace'},
{key: 'Delete'},
{key: 'F2'},
{key: 'Escape'},
{key: 'c', metaKey: true},
{key: 'x', metaKey: true},
{key: 'v', metaKey: true},
];
function FileTreeBody({projectRoot, setProjectRoot}) {
const styles = useThemedStyles(createStyles);
const styles = useThemedStyles(createStyles);
const [files, setFiles] = useState([]);
const {
menu,
draft,
pendingDelete,
selected,
clipboard,
problem,
treeRef,
openMenu,
closeMenu,
startCreate,
startRename,
requestDelete,
refreshAll,
collapseAll,
copy,
cut,
paste,
dismissProblem,
} = useFileTree();
useEffect(() => {
async function fetchFiles() {
if (!projectRoot) {
return;
}
// A click reports where it landed relative to the root view, and `measure`
// reports view positions in that same frame — the one convention the whole
// touch system is built on. Knowing where the panel starts is therefore
// enough to turn a click anywhere into a point inside it.
const [frame, setFrame] = useState({pageX: 0, pageY: 0, width: 0, height: 0});
const unsortedFiles = await fileSystem.listFiles(projectRoot, '');
const sortedFiles = sortFiles(unsortedFiles.contents ?? []);
setFiles(sortedFiles);
function measure() {
treeRef.current?.measure?.((x, y, width, height, pageX, pageY) => setFrame({pageX, pageY, width, height}));
}
}
fetchFiles();
}, [projectRoot]);
function openRootMenu(event) {
const {pageX = 0, pageY = 0} = event?.nativeEvent ?? {};
openMenu({entry: null, pageX, pageY});
}
function handleFileSelect(file) {
if (file.isDirectory) {
return openDirectory(file);
/**
* The shortcuts act on the selected row. Both of the things that take the
* keyboard away from the tree — the inline name field and the delete dialog
* — send their own keystrokes bubbling back through here on the way out, so
* while either is open this stands down rather than acting on them twice.
*/
// A file is pasted beside itself; a folder is pasted inside itself. With
// nothing selected the project root is what is being talked about.
function pasteTarget() {
if (!selected) {
return '';
}
}
return onOpenFile(file.fullPath);
return selected.isDirectory ? selected.fullPath : parentOf(selected.fullPath);
}
}
async function openDirectory(file) {
const pathToOpen = file.fullPath;
function onKeyDown(event) {
const {key, metaKey, ctrlKey} = event.nativeEvent ?? event;
const response = await fileSystem.listFiles(projectRoot, pathToOpen);
const folderItems = response.contents ?? [];
if (draft || pendingDelete) {
return;
}
// Paste is the one that works with nothing selected, so the clipboard keys
// are answered before the tree asks whether there is a row to act on.
if (metaKey || ctrlKey) {
if (key === 'v') {
return paste(pasteTarget());
}
file.items = folderItems;
if (key === 'c' && selected) {
return copy(selected);
}
let updatedFiles = [...files];
updatedFiles = updatedFiles.map(f => {
if (f.name === file.name) {
return file;
if (key === 'x' && selected) {
return cut(selected);
}
}
return f;
return;
});
setFiles(updatedFiles);
}
if (key === 'Escape') {
return closeMenu();
}
if (!selected) {
return;
}
if (key === 'Enter' || key === 'F2') {
return startRename(selected);
}
// A full-size keyboard's forward delete arrives as `Delete`; the key most
// Macs actually have is reported as `Backspace`.
if (key === 'Backspace' || key === 'Delete') {
return requestDelete(selected);
}
}
}
// Handing focus back once the field or the dialog is gone, so the next
// Return lands on the tree rather than on nothing.
const wasEditing = useRef(false);
useEffect(() => {
const editing = Boolean(draft || pendingDelete);
if (wasEditing.current && !editing) {
treeRef.current?.focus?.();
}
wasEditing.current = editing;
}, [draft, pendingDelete, treeRef]);
async function openFolder() {
async function openFolder() {
const path = await NativeModules.FolderPicker.pick();
const path = await NativeModules.FolderPicker.pick();
setProjectRoot(path);
setProjectRoot(path);
}
}
// Drawn down and to the right of the pointer, then held inside the panel so
// a click near an edge does not put the menu somewhere it cannot be reached.
const menuSize = menu && rowMenuSize(menu.entry, clipboard);
const menuPosition = menu && {
left: clamp(menu.pageX - frame.pageX, 4, frame.width - menuSize.width - 4),
top: clamp(menu.pageY - frame.pageY, 4, frame.height - menuSize.height - 4),
};
return (
return (
<ScrollView style={styles.editor}>
<View
ref={treeRef}
focusable
enableFocusRing={false}
onLayout={measure}
onKeyDown={onKeyDown}
keyDownEvents={KEY_DOWN_EVENTS}
style={styles.panel}>
<View style={[styles.header, !projectRoot && styles.headerWithoutProject]}>
<Pressable onPress={() => openFolder()} style={[styles.openFoler, !projectRoot && styles.noProjectRootRow]}>
<Pressable onPress={openFolder} style={[styles.openFolder, !projectRoot && styles.noProjectRootRow]}>
<Text style={[styles.openFolderText, !projectRoot && styles.noProjectRootRowText]}>Open folder</Text>
<Text style={[styles.openFolderText, !projectRoot && styles.noProjectRootRowText]}>Open folder</Text>
</Pressable>
</Pressable>
</View>
{files.map(file => (
<View key={file.name}>
{/* Top-level files never reach FileNode, so they get the same row
treatment here: chevron-width gutter, icon, name. */}
{!file.isDirectory && (
<Pressable style={styles.row} onPress={() => handleFileSelect(file)}>
<View style={styles.chevronSpacer} />
<FileIcon name={file.name} />
<Text>{file.name}</Text>
{problem && (
<Pressable onPress={dismissProblem} style={styles.problem}>
<Text style={styles.problemText}>{problem}</Text>
</Pressable>
</Pressable>
)}
)}
{file.isDirectory && (
<FileNode
projectRoot={projectRoot}
key={file.fullPath}
folder={file}
onOpenFile={onOpenFile}
itemsDeep={0}
/>
)}
{/* The empty space under the rows is still the project, so a click there
means the project — which is what makes "new file at the top level"
reachable without aiming at a particular row. Asked on the way back
up rather than on the way down, so a row that wants the click keeps
it and only what nothing else claimed arrives here. */}
<ScrollView style={styles.tree} contentContainerStyle={styles.treeContent}>
<View
style={styles.treeContent}
onStartShouldSetResponder={isSecondaryClick}
onResponderGrant={openRootMenu}>
<DirectoryContents directoryPath="" depth={0} />
</View>
</View>
))}
</ScrollView>
</ScrollView>
{menu && <RowMenu left={menuPosition.left} top={menuPosition.top} />}
{pendingDelete && <ConfirmDelete />}
</View>
);
);
}
}
const createStyles = colors => StyleSheet.create({
const createStyles = colors => StyleSheet.create({
panel: {
...glass(colors, {tint: 0.25}),
marginBottom: 16,
marginTop: 16,
padding: 16,
borderRadius: 16,
flex: 1,
// The menu and the delete dialog are absolutely placed against this panel,
// so it has to be the frame they resolve against.
position: 'relative',
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
// With nothing open the header is only the call to action, which is allowed
// the full width rather than a row's worth of it.
headerWithoutProject: {
flexDirection: 'column',
alignItems: 'stretch',
marginBottom: 16,
},
openFolder: {
flexShrink: 1,
},
openFolderText: {
color: colors.onSurfaceVariant,
},
noProjectRootRow: {
noProjectRootRow: {
borderRadius: 16,
borderRadius: 16,
padding: 16,
padding: 16,
color: colors.onPrimary,
color: colors.onPrimary,
textAlign: 'center',
textAlign: 'center',
},
},
openFoler: {
marginBottom: 16,
},
row: {
headerActions: {
flexDirection: 'row',
flexDirection: 'row',
gap: 4,
alignItems: 'center',
alignItems: 'center',
marginBottom: 4,
gap: 2,
marginLeft: 'auto',
},
},
chevronSpacer: {
width: 16,
headerAction: {
padding: 4,
borderRadius: 6,
},
},
editor: {
...glass(colors, {tint: 0.25}),
headerActionHovered: {
backgroundColor: withAlpha(colors.onSurface, 0.08),
},
// What a copy, paste or delete says when it fails, since none of them has an
// inline field to complain in. Tapping it is how it goes away.
problem: {
marginBottom: 16,
marginBottom: 8,
marginTop: 16,
padding: 16,
padding: 8,
borderRadius: 16,
borderRadius: 8,
backgroundColor: withAlpha(colors.error, 0.15),
},
problemText: {
color: colors.error,
fontSize: 12,
},
tree: {
flex: 1,
flex: 1,
},
},
// Grown to fill the panel so the space below the last row is part of the
// tree's own surface rather than the scroll view's backdrop.
treeContent: {
flexGrow: 1,
},
glyph: {
color: colors.onSurfaceVariant,
},
});
});
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {Icon} from '../icons';
import {parentOf, useFileTree} from './fileTreeContext';
import {useState} from 'react';
const ITEM_HEIGHT = 30;
const MENU_WIDTH = 190;
/**
* Where new entries made from a menu go: inside a folder, beside a file — which
* is the same thing said about its parent — and at the top level for the empty
* space below the rows, which stands for the project itself.
*/
function parentForNewEntries(entry) {
if (!entry) {
return '';
}
return entry.isDirectory ? entry.fullPath : parentOf(entry.fullPath);
}
/**
* How tall the menu will be, so the tree can keep it inside the panel before
* it is drawn. The project root has nothing to rename, copy or delete, so its
* menu is short; Paste only appears when there is something to paste.
*/
export function rowMenuSize(entry, clipboard) {
// New file and New folder always; then Cut, Copy, Rename and Delete for a
// row; then Paste whenever the clipboard holds something.
const items = 2 + (entry ? 4 : 0) + (clipboard ? 1 : 0);
return {width: MENU_WIDTH, height: ITEM_HEIGHT * items + 8};
}
/**
* The tree's context menu, opened by a secondary click on a row or on the
* space below them — and by a long press, for anyone without a second button.
*
* Positioned by the tree, which is the only thing that knows where its own
* panel starts; the menu itself is handed coordinates it can use.
*/
export function RowMenu({left, top}) {
const styles = useThemedStyles(createStyles);
const {menu, clipboard, closeMenu, startCreate, startRename, requestDelete, copy, cut, paste} = useFileTree();
const entry = menu.entry;
const parent = parentForNewEntries(entry);
const items = [
{label: 'New file', icon: 'new-file', onPress: () => startCreate(parent, 'file')},
{label: 'New folder', icon: 'new-folder', onPress: () => startCreate(parent, 'directory')},
];
if (entry) {
items.push(
{label: 'Cut', icon: 'cut', onPress: () => cut(entry)},
{label: 'Copy', icon: 'copy', onPress: () => copy(entry)},
);
}
// Offered only when it would do something. A greyed-out row that never
// becomes available says less than no row at all.
if (clipboard) {
items.push({label: `Paste ${clipboard.entry.name}`, icon: 'paste', onPress: () => paste(parent)});
}
if (entry) {
items.push(
{label: 'Rename', icon: 'rename', onPress: () => startRename(entry)},
{label: 'Delete', icon: 'trash', onPress: () => requestDelete(entry), destructive: true},
);
}
return (
<>
<Pressable style={StyleSheet.absoluteFill} onPress={closeMenu} />
<View style={[styles.menu, {left, top}]}>
{items.map(item => (
<MenuItem key={item.icon} {...item} />
))}
</View>
</>
);
}
function MenuItem({label, icon, onPress, destructive}) {
const styles = useThemedStyles(createStyles);
const [hovered, setHovered] = useState(false);
const color = destructive ? styles.destructive.color : styles.label.color;
return (
<Pressable
style={[styles.item, hovered && styles.itemHovered]}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
onPress={onPress}>
<Icon name={icon} size={16} color={color} />
<Text numberOfLines={1} style={[styles.label, destructive && styles.destructive]}>
{label}
</Text>
</Pressable>
);
}
const createStyles = colors => StyleSheet.create({
menu: {
position: 'absolute',
width: MENU_WIDTH,
padding: 4,
borderRadius: 10,
...glass(colors, {tint: 0.95}),
backgroundColor: colors.surfaceAt2,
shadowColor: colors.shadow,
shadowOffset: {width: 0, height: 8},
shadowOpacity: 0.35,
shadowRadius: 24,
},
item: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
height: ITEM_HEIGHT,
paddingHorizontal: 8,
borderRadius: 6,
},
itemHovered: {
backgroundColor: withAlpha(colors.onSurface, 0.08),
},
label: {
color: colors.onSurface,
flexShrink: 1,
},
destructive: {
color: colors.error,
},
});
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import {useEffect, useRef, useState} from 'react';
import {useTheme, useThemedStyles, withAlpha} from '../theme';
import {FileIcon} from '../icons/FileIcon';
import {Icon} from '../icons';
import {useFileTree} from './fileTreeContext';
// One row's worth of indentation per level, and a gutter the width of the
// chevron so a folder's contents line up under its name, not under its arrow.
const INDENT = 12;
const GUTTER = 16;
/**
* Escape is listed for a different reason than the keys claimed elsewhere.
* A single-line field on macOS never sees it as a key *down*: the field editor
* turns it into `cancelOperation:` first, and react-native-macos answers that
* by reporting a key *press* — which is why the handler below is `onKeyPress`.
* What listing it here does is stop the field editor resigning focus on the
* way through, leaving the cancel entirely ours to perform.
*/
const KEY_DOWN_EVENTS = [{key: 'Escape'}];
function indentFor(depth) {
return 4 + depth * INDENT;
}
// AppKit sends a secondary click through the same path as a primary one, and
// the touch it produces says which button it came from — but `Pressability`
// drops every non-primary button on the floor, so `onPress` and friends never
// hear about it. The responder system underneath them does, which is why the
// menu is opened from there rather than from a `Pressable`.
export const SECONDARY_BUTTON = 2;
export function isSecondaryClick(event) {
return (event?.nativeEvent?.button ?? 0) === SECONDARY_BUTTON;
}
/**
* A row for something that exists on disk. Pressing it selects it — which is
* what the tree's keyboard shortcuts act on — and then opens or expands it.
*/
export function TreeRow({entry, depth, isOpen, onPress}) {
const styles = useThemedStyles(createStyles);
const colors = useTheme();
const {currentFile, selected, clipboard, select, openMenu, focusTree} = useFileTree();
const [hovered, setHovered] = useState(false);
const isCurrent = !entry.isDirectory && currentFile === entry.fullPath;
const isSelected = selected?.fullPath === entry.fullPath;
// Nothing has moved yet — a cut only happens on paste — so the row is still
// here, faded to say where it is headed.
const isCut = clipboard?.mode === 'cut' && clipboard.entry.fullPath === entry.fullPath;
// Selecting first, always: a right-click on a row is a statement about that
// row, not about whichever one happened to be selected before it.
function selectRow() {
select(entry);
focusTree();
}
/**
* Opened where the pointer is, like any other menu on this platform. Touch
* positions and view measurements share one frame — both are relative to the
* root view — so the tree can subtract its own origin and get a point.
*/
function openRowMenu(event) {
const {pageX = 0, pageY = 0} = event?.nativeEvent ?? {};
selectRow();
openMenu({entry, pageX, pageY});
}
return (
// Claimed during the capture phase, which is the only way to get in front
// of the `Pressable` below — it says yes to everything, and the deepest
// willing view wins the bubble. Only a secondary click is taken, so an
// ordinary press falls through to the row itself untouched.
<View onStartShouldSetResponderCapture={isSecondaryClick} onResponderGrant={openRowMenu}>
<Pressable
style={[
styles.row,
{paddingLeft: indentFor(depth)},
hovered && styles.rowHovered,
isCurrent && styles.rowCurrent,
isSelected && styles.rowSelected,
isCut && styles.rowCut,
]}
onPressIn={selectRow}
onPress={onPress}
onLongPress={openRowMenu}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}>
{entry.isDirectory ? (
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={GUTTER} color={colors.onSurfaceVariant} />
) : (
<View style={styles.gutter} />
)}
<FileIcon name={entry.name} isDirectory={entry.isDirectory} isOpen={isOpen} />
<Text numberOfLines={1} style={[styles.name, isCurrent && styles.nameCurrent]}>
{entry.name}
</Text>
</Pressable>
</View>
);
}
/**
* The row that is being typed rather than read: one of these replaces a name
* while it is renamed, and one sits inside a folder while a new entry is named.
*
* The text stays local. The tree above only needs the finished name, and
* putting every keystroke through the shared state would redraw every row in
* the project to show one changing character.
*
* Committing on blur is deliberate — the field is dismissed by clicking away
* from it far more often than by pressing Return, and losing the name to that
* is the annoying half of the trade. Escape is the way to mean "no".
*/
export function DraftRow({depth}) {
const styles = useThemedStyles(createStyles);
const {draft, commitDraft, cancelDraft, clearDraftError} = useFileTree();
const input = useRef(null);
const [name, setName] = useState(draft.initialName);
// Return both commits and blurs. Without this the blur handler would run a
// second time against a draft the commit has not finished clearing yet.
const settled = useRef(false);
useEffect(() => {
input.current?.focus?.();
}, []);
const isDirectory = draft.mode === 'create' ? draft.type === 'directory' : draft.isDirectory;
function settle(action) {
if (settled.current) {
return;
}
settled.current = true;
action();
}
function onChangeText(next) {
setName(next);
// Whatever the last commit complained about is stale the moment the name
// changes. Nothing else here needs the tree to redraw.
if (draft.error) {
clearDraftError();
}
}
// A rejected name leaves the field open to be fixed, so the guard has to lift
// again — otherwise the second attempt would be ignored.
function commit() {
settle(() => {
commitDraft(name).then(committed => {
if (!committed) {
settled.current = false;
}
});
});
}
// Wired to both: macOS delivers Escape as a press, the web build as a down.
function onCancelKey(event) {
if ((event.nativeEvent ?? event).key === 'Escape') {
settle(cancelDraft);
}
}
return (
<View>
<View style={[styles.row, {paddingLeft: indentFor(depth)}]}>
<View style={styles.gutter} />
<FileIcon name={name || 'file'} isDirectory={isDirectory} isOpen={false} />
<TextInput
ref={input}
style={styles.input}
value={name}
placeholder={isDirectory ? 'Folder name' : 'File name'}
enableFocusRing={false}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
onChangeText={onChangeText}
onKeyPress={onCancelKey}
onKeyDown={onCancelKey}
keyDownEvents={KEY_DOWN_EVENTS}
onSubmitEditing={commit}
onBlur={commit}
/>
</View>
{draft.error && <Text style={[styles.error, {paddingLeft: indentFor(depth) + GUTTER + 20}]}>{draft.error}</Text>}
</View>
);
}
const createStyles = colors => StyleSheet.create({
row: {
flexDirection: 'row',
gap: 4,
alignItems: 'center',
paddingRight: 4,
height: 26,
borderRadius: 6,
},
rowHovered: {
backgroundColor: withAlpha(colors.onSurface, 0.06),
},
// Two different things worth showing: which file the editor has open, and
// which row the keyboard is pointing at. The second is the louder of the two,
// because it is the one about to be renamed or deleted.
rowCurrent: {
backgroundColor: withAlpha(colors.primary, 0.12),
},
rowSelected: {
backgroundColor: withAlpha(colors.primary, 0.28),
},
rowCut: {
opacity: 0.45,
},
gutter: {
width: GUTTER,
},
name: {
color: colors.onSurface,
flexShrink: 1,
},
nameCurrent: {
fontWeight: '600',
},
input: {
flex: 1,
marginLeft: 2,
paddingVertical: 2,
paddingHorizontal: 6,
borderRadius: 6,
borderWidth: 1,
borderColor: colors.primary,
backgroundColor: colors.surface,
color: colors.onSurface,
},
error: {
color: colors.error,
fontSize: 11,
paddingTop: 2,
paddingBottom: 2,
},
});
import {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react';
import {fileSystem} from '../fileSystem';
import {sortFiles} from './sortFiles';
/**
* The tree used to keep its rows where it found them: `FileTree` held the top
* level and every `FileNode` held its own children, which is enough to browse
* with and not enough to edit with. A rename has to redraw the folder it
* happened in, and nothing could reach into a node from outside to say so.
*
* So the shape of the tree lives here instead, keyed by directory path — `''`
* is the project root — and the rows became a projection of it. Editing is
* then only ever "change the disk, reload the directories that moved".
*/
const FileTreeContext = createContext(null);
export function useFileTree() {
return useContext(FileTreeContext);
}
export function parentOf(entryPath) {
const cut = entryPath.lastIndexOf('/');
return cut === -1 ? '' : entryPath.slice(0, cut);
}
export function joinPath(directoryPath, name) {
return directoryPath ? `${directoryPath}/${name}` : name;
}
/**
* What the inline field will not let you commit. Anything the server would
* also refuse is checked there too — this is only about telling the user
* before the round trip, the way VS Code's explorer does.
*
* A separator is deliberately *not* rejected: typing `models/user.rb` into the
* new-file field is meant to create the folder on the way, so the rules are
* about the segments rather than the string.
*/
function validateName(name, siblings, ignoredPath) {
const trimmed = name.trim();
if (!trimmed) {
return 'A name is required';
}
const segments = trimmed.split('/');
if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) {
return 'That is not a valid path';
}
const taken = siblings.some(
entry => entry.name.toLowerCase() === segments[0].toLowerCase() && entry.fullPath !== ignoredPath,
);
if (taken && segments.length === 1) {
return `${segments[0]} already exists here`;
}
return null;
}
export function FileTreeProvider({projectRoot, currentFile, onOpenFile, onEntryRenamed, onEntryRemoved, children}) {
// Directory path -> its sorted contents. A key being absent means "never
// looked at"; an empty array means "looked at, and it is empty".
const [entries, setEntries] = useState({});
const [expanded, setExpanded] = useState({});
// The one inline field the tree can have open: either a new entry being
// named inside `parentPath`, or `targetPath` being renamed. Only one at a
// time, because there is only one keyboard.
const [draft, setDraft] = useState(null);
const [menu, setMenu] = useState(null);
const [pendingDelete, setPendingDelete] = useState(null);
// The row the keyboard is talking about. Clicking a row selects it as well
// as opening it, which is what gives Return and Delete something to act on
// without the tree having to make every row separately focusable.
const [selected, setSelected] = useState(null);
// What Cmd+C or Cmd+X put aside, and which of the two it was. A cut is only
// carried out on paste, so until then nothing has moved and the entry is
// still where it was — only shown faded, to say it is on its way out.
const [clipboard, setClipboard] = useState(null);
// Where a failed rename or delete would have shown its complaint, there is a
// field to put it in. Copy, paste and delete have no field, so they need
// somewhere of their own rather than failing in silence.
const [problem, setProblem] = useState(null);
// The panel itself. On macOS keys are only delivered to the first responder,
// and a click deliberately does not move focus, so the rows have to hand it
// back here explicitly or the shortcuts would only work by accident.
const tree = useRef(null);
const load = useCallback(
async directoryPath => {
if (!projectRoot) {
return;
}
const response = await fileSystem.listFiles(projectRoot, directoryPath);
setEntries(current => ({...current, [directoryPath]: sortFiles(response.contents ?? [])}));
},
[projectRoot],
);
// A new project shares nothing with the last one — not the rows, not which
// folders were open, and certainly not a half-typed name.
useEffect(() => {
setEntries({});
setExpanded({});
setDraft(null);
setMenu(null);
setPendingDelete(null);
setSelected(null);
setClipboard(null);
setProblem(null);
load('');
}, [projectRoot, load]);
/**
* Renaming or deleting a folder invalidates everything cached beneath it,
* and those rows would otherwise sit there addressing paths that no longer
* exist. Dropping the subtree is cheaper than rewriting it, and the folder
* is reloaded from disk the next time it is opened anyway.
*/
const forgetSubtree = useCallback(directoryPath => {
const prefix = `${directoryPath}/`;
const isBeneath = key => key === directoryPath || key.startsWith(prefix);
const without = record => Object.fromEntries(Object.entries(record).filter(([key]) => !isBeneath(key)));
setEntries(without);
setExpanded(without);
}, []);
/**
* Opens every folder on the way to a path and loads them, so something just
* created is visible where it landed rather than behind a closed folder —
* which is the whole point of letting the name carry a folder in it.
*/
const reveal = useCallback(
async (entryPath, expandSelf) => {
const segments = entryPath.split('/');
const directories = [''];
for (let index = 0; index < segments.length - 1; index++) {
directories.push(joinPath(directories[index], segments[index]));
}
if (expandSelf) {
directories.push(entryPath);
}
setExpanded(current => ({...current, ...Object.fromEntries(directories.map(directory => [directory, true]))}));
await Promise.all(directories.map(load));
},
[load],
);
const toggleDirectory = useCallback(
directoryPath => {
const isOpen = expanded[directoryPath];
setExpanded(current => ({...current, [directoryPath]: !isOpen}));
if (!isOpen) {
// Always re-read on open: the terminal below the editor is writing to
// the same disk, so a cached listing is only ever a guess.
load(directoryPath);
}
},
[expanded, load],
);
const collapseAll = useCallback(() => setExpanded({}), []);
const refreshAll = useCallback(() => {
Object.keys(entries).forEach(load);
}, [entries, load]);
const startCreate = useCallback(
(parentPath, type) => {
setMenu(null);
setExpanded(current => ({...current, [parentPath]: true}));
load(parentPath);
setDraft({mode: 'create', parentPath, type, initialName: '', error: null});
},
[load],
);
const startRename = useCallback(entry => {
setMenu(null);
setDraft({mode: 'rename', targetPath: entry.fullPath, isDirectory: entry.isDirectory, initialName: entry.name, error: null});
}, []);
const cancelDraft = useCallback(() => setDraft(null), []);
const clearDraftError = useCallback(() => {
setDraft(current => (current?.error ? {...current, error: null} : current));
}, []);
/**
* Answers whether the field is finished with. A name the disk or the tree
* refuses leaves it open with something to fix, and the row it belongs to
* needs to know that to keep taking input.
*/
const commitDraft = useCallback(
async name => {
if (!draft) {
return true;
}
const parentPath = draft.mode === 'rename' ? parentOf(draft.targetPath) : draft.parentPath;
const nameProblem = validateName(name, entries[parentPath] ?? [], draft.targetPath);
if (nameProblem) {
setDraft(current => (current ? {...current, error: nameProblem} : current));
return false;
}
const destination = joinPath(parentPath, name.trim());
try {
if (draft.mode === 'rename') {
if (destination === draft.targetPath) {
setDraft(null);
return true;
}
await fileSystem.renameEntry(projectRoot, draft.targetPath, destination);
setDraft(null);
if (draft.isDirectory) {
forgetSubtree(draft.targetPath);
}
await reveal(destination, false);
setSelected(current =>
current?.fullPath === draft.targetPath
? {...current, name: destination.split('/').pop(), fullPath: destination}
: current,
);
onEntryRenamed?.(draft.targetPath, destination);
return true;
}
await fileSystem.createEntry(projectRoot, destination, draft.type);
setDraft(null);
await reveal(destination, draft.type === 'directory');
if (draft.type === 'file') {
onOpenFile?.(destination);
}
return true;
} catch (error) {
setDraft(current => (current ? {...current, error: error.message} : current));
return false;
}
},
[draft, entries, projectRoot, reveal, forgetSubtree, onEntryRenamed, onOpenFile],
);
const copy = useCallback(entry => {
setMenu(null);
setProblem(null);
setClipboard({entry, mode: 'copy'});
}, []);
const cut = useCallback(entry => {
setMenu(null);
setProblem(null);
setClipboard({entry, mode: 'cut'});
}, []);
/**
* A cut and a copy are different operations on the disk but the same one
* here: put the entry under `directoryPath`. Moving is the rename endpoint
* seen from another angle, and copying is a create whose contents come from
* somewhere else — which is also why only the copy renames itself out of a
* collision. A move onto an existing name is a question for the user, not
* something to answer with a second file.
*/
const paste = useCallback(
async directoryPath => {
setMenu(null);
setProblem(null);
if (!clipboard) {
return;
}
const {entry, mode} = clipboard;
const destination = joinPath(directoryPath, entry.name);
// Pasting a folder into itself, or into anything inside it, would put the
// copy inside the thing being copied. The server refuses it too; catching
// it here is what turns it into a sentence instead of a failed request.
if (entry.isDirectory && (directoryPath === entry.fullPath || directoryPath.startsWith(`${entry.fullPath}/`))) {
setProblem(`${entry.name} cannot be pasted into itself`);
return;
}
try {
if (mode === 'cut') {
// Already where it is being sent. Nothing to do, and the rename
// endpoint would rightly call it a collision.
if (parentOf(entry.fullPath) === directoryPath) {
setClipboard(null);
return;
}
await fileSystem.renameEntry(projectRoot, entry.fullPath, destination);
setClipboard(null);
if (entry.isDirectory) {
forgetSubtree(entry.fullPath);
}
await load(parentOf(entry.fullPath));
await reveal(destination, false);
onEntryRenamed?.(entry.fullPath, destination);
return;
}
// The name it was given may already have been taken, so where the copy
// landed is the server's answer rather than ours.
const response = await fileSystem.copyEntry(projectRoot, destination, entry.fullPath);
await reveal(response.path ?? destination, false);
} catch (error) {
setProblem(error.message);
}
},
[clipboard, projectRoot, load, reveal, forgetSubtree, onEntryRenamed],
);
const requestDelete = useCallback(entry => {
setMenu(null);
setProblem(null);
setPendingDelete(entry);
}, []);
const confirmDelete = useCallback(async () => {
const entry = pendingDelete;
if (!entry) {
return;
}
setPendingDelete(null);
try {
await fileSystem.deleteEntry(projectRoot, entry.fullPath);
} catch (error) {
setProblem(error.message);
return;
}
setSelected(current => (current?.fullPath === entry.fullPath ? null : current));
setClipboard(current => (current?.entry.fullPath === entry.fullPath ? null : current));
if (entry.isDirectory) {
forgetSubtree(entry.fullPath);
}
await load(parentOf(entry.fullPath));
onEntryRemoved?.(entry.fullPath, entry.isDirectory);
}, [pendingDelete, projectRoot, load, forgetSubtree, onEntryRemoved]);
const value = {
projectRoot,
currentFile,
entries,
expanded,
draft,
menu,
pendingDelete,
selected,
clipboard,
problem,
treeRef: tree,
onOpenFile,
select: setSelected,
focusTree: () => tree.current?.focus?.(),
toggleDirectory,
collapseAll,
refreshAll,
startCreate,
startRename,
cancelDraft,
clearDraftError,
commitDraft,
openMenu: setMenu,
closeMenu: () => setMenu(null),
copy,
cut,
paste,
dismissProblem: () => setProblem(null),
requestDelete,
confirmDelete,
cancelDelete: () => setPendingDelete(null),
};
return <FileTreeContext.Provider value={value}>{children}</FileTreeContext.Provider>;
}
});
});
},
},
/**
* Creating, renaming and deleting all go through `/entry`, and all three are
* shown to the user the moment they fail — an existing name is a normal thing
* to type, not an exception to swallow. So unlike the readers above, these
* reject with what the server said rather than with a status code.
*/
createEntry(projectRoot, path, type) {
return this.mutate("POST", "/entry", projectRoot, path, {type});
},
/**
* Copying is a create whose contents come from somewhere else, so it answers
* with the path it actually used — a name already taken beside the original
* is normal, and the server makes it unique rather than refusing.
*/
copyEntry(projectRoot, path, copyFrom) {
return this.mutate("POST", "/entry", projectRoot, path, {copyFrom});
},
renameEntry(projectRoot, path, newPath) {
return this.mutate("PATCH", "/entry", projectRoot, path, {newPath});
},
deleteEntry(projectRoot, path) {
return this.mutate("DELETE", "/entry", projectRoot, path);
},
async mutate(method, url, projectRoot, path, body = null) {
const fullUrl = `${url}?path=${encodeURIComponent(path)}&projectRoot=${encodeURIComponent(projectRoot)}`;
const response = await fetch(BASE_URL + fullUrl, {
method,
headers: {...this.defaultHeaders},
body: body ? JSON.stringify(body) : undefined,
});
const isJson = response.headers.get("content-type")?.includes("application/json");
const data = isJson ? await response.json() : await response.text();
if (!response.ok) {
throw new Error(data?.error ?? `${method} ${fullUrl} failed with ${response.status}`);
}
return data;
},
makeRequest(projectRoot, url, path = null, headers = {}) {
makeRequest(projectRoot, url, path = null, headers = {}) {
const fullUrl = path ? `${url}?path=${encodeURIComponent(path)}&projectRoot=${encodeURIComponent(projectRoot)}` : `${url}?projectRoot=${encodeURIComponent(projectRoot)}`;
const fullUrl = path ? `${url}?path=${encodeURIComponent(path)}&projectRoot=${encodeURIComponent(projectRoot)}` : `${url}?projectRoot=${encodeURIComponent(projectRoot)}`;
const options = {
const options = {
import {Glyph, strokeProps} from './glyph';
import {Path} from 'react-native-svg';
import React from 'react';
export function CopyIcon({size, color}) {
return (
<Glyph size={size}>
<Path d="M9.4 8.6h9.2a1.4 1.4 0 0 1 1.4 1.4v9.2a1.4 1.4 0 0 1-1.4 1.4H9.4A1.4 1.4 0 0 1 8 19.2V10a1.4 1.4 0 0 1 1.4-1.4z" stroke={color} {...strokeProps} />
<Path d="M16 5.6V4.8a1.4 1.4 0 0 0-1.4-1.4H5.4A1.4 1.4 0 0 0 4 4.8V14a1.4 1.4 0 0 0 1.4 1.4h.8" stroke={color} {...strokeProps} />
</Glyph>
);
}
import {Circle, Path} from 'react-native-svg';
import {Glyph, strokeProps} from './glyph';
import React from 'react';
export function CutIcon({size, color}) {
return (
<Glyph size={size}>
<Circle cx="6.4" cy="18" r="2.8" stroke={color} {...strokeProps} />
<Circle cx="17.6" cy="18" r="2.8" stroke={color} {...strokeProps} />
<Path d="M8.4 15.8 17.2 3.4M15.6 15.8 6.8 3.4" stroke={color} {...strokeProps} />
</Glyph>
);
}
import {ChevronRight} from './chevronRight';
import {ChevronRight} from './chevronRight';
import {ChevronTop} from './chevronTop';
import {ChevronTop} from './chevronTop';
import {CodeIcon} from './code';
import {CodeIcon} from './code';
import {CollapseAllIcon} from './collapseAll';
import {CopyIcon} from './copy';
import {CutIcon} from './cut';
import {FilesIcon} from './files';
import {FilesIcon} from './files';
import {GithubIcon} from './github';
import {GithubIcon} from './github';
import {HomeIcon} from './home';
import {HomeIcon} from './home';
import {NewFileIcon} from './newFile';
import {NewFolderIcon} from './newFolder';
import {PasteIcon} from './paste';
import {PatheIcon} from './pathe';
import {PatheIcon} from './pathe';
import React from 'react';
import React from 'react';
import {RefreshIcon} from './refresh';
import {RenameIcon} from './rename';
import {SearchIcon} from './search';
import {SearchIcon} from './search';
import {TrashIcon} from './trash';
import {WorkIcon} from './work';
import {WorkIcon} from './work';
export {LogoIcon} from './logo';
export {LogoIcon} from './logo';
'chevron-down': ChevronDown,
'chevron-down': ChevronDown,
files: FilesIcon,
files: FilesIcon,
search: SearchIcon,
search: SearchIcon,
'new-file': NewFileIcon,
'new-folder': NewFolderIcon,
rename: RenameIcon,
trash: TrashIcon,
refresh: RefreshIcon,
copy: CopyIcon,
cut: CutIcon,
paste: PasteIcon,
};
};
export function Icon({name, size, color}) {
export function Icon({name, size, color}) {
import {Glyph, strokeProps} from './glyph';
import {Path} from 'react-native-svg';
import React from 'react';
export function NewFileIcon({size, color}) {
return (
<Glyph size={size}>
<Path d="M13.2 2.9H6.9a1.4 1.4 0 0 0-1.4 1.4v15.4a1.4 1.4 0 0 0 1.4 1.4h4.3" stroke={color} {...strokeProps} />
<Path d="M13.2 2.9 18.5 8.2v3.4M13.2 2.9v5.3h5.3" stroke={color} {...strokeProps} />
<Path d="M17.4 14.6v6.4M14.2 17.8h6.4" stroke={color} {...strokeProps} />
</Glyph>
);
}
import {Glyph, strokeProps} from './glyph';
import {Path} from 'react-native-svg';
import React from 'react';
export function NewFolderIcon({size, color}) {
return (
<Glyph size={size}>
<Path d="M11.4 14.6H3.6a1.4 1.4 0 0 1-1.4-1.4V5.1a1.4 1.4 0 0 1 1.4-1.4h3.6l2 2.4h6.6a1.4 1.4 0 0 1 1.4 1.4v3.1" stroke={color} {...strokeProps} />
<Path d="M17.4 14.6v6.4M14.2 17.8h6.4" stroke={color} {...strokeProps} />
</Glyph>
);
}
import {Glyph, strokeProps} from './glyph';
import {Path} from 'react-native-svg';
import React from 'react';
export function PasteIcon({size, color}) {
return (
<Glyph size={size}>
<Path d="M9 4.6H6.4A1.4 1.4 0 0 0 5 6v13.4a1.4 1.4 0 0 0 1.4 1.4h11.2a1.4 1.4 0 0 0 1.4-1.4V6a1.4 1.4 0 0 0-1.4-1.4H15" stroke={color} {...strokeProps} />
<Path d="M9.6 3.2h4.8a.8.8 0 0 1 .8.8v1.8a.8.8 0 0 1-.8.8H9.6a.8.8 0 0 1-.8-.8V4a.8.8 0 0 1 .8-.8z" stroke={color} {...strokeProps} />
<Path d="M8.6 12.4h6.8M8.6 16h4.6" stroke={color} {...strokeProps} />
</Glyph>
);
}
import {Glyph, strokeProps} from './glyph';
import {Path} from 'react-native-svg';
import React from 'react';
export function RefreshIcon({size, color}) {
return (
<Glyph size={size}>
<Path d="M20.2 11.4a8.2 8.2 0 1 1-2.4-5.6" stroke={color} {...strokeProps} />
<Path d="M20.4 3.2v4.8h-4.8" stroke={color} {...strokeProps} />
</Glyph>
);
}
import {Glyph, strokeProps} from './glyph';
import {Path} from 'react-native-svg';
import React from 'react';
export function RenameIcon({size, color}) {
return (
<Glyph size={size}>
<Path d="M16.4 3.6a2.3 2.3 0 0 1 3.2 3.2L8.4 18.1l-4.2 1 1-4.2z" stroke={color} {...strokeProps} />
<Path d="M14.6 5.4 17.8 8.6" stroke={color} {...strokeProps} />
</Glyph>
);
}
import {Glyph, strokeProps} from './glyph';
import {Path} from 'react-native-svg';
import React from 'react';
export function TrashIcon({size, color}) {
return (
<Glyph size={size}>
<Path d="M3.8 6.4h16.4" stroke={color} {...strokeProps} />
<Path d="M9.4 6.4V4.2a1 1 0 0 1 1-1h3.2a1 1 0 0 1 1 1v2.2" stroke={color} {...strokeProps} />
<Path d="M6.2 6.4 7.3 20a1 1 0 0 0 1 .9h7.4a1 1 0 0 0 1-.9l1.1-13.6" stroke={color} {...strokeProps} />
<Path d="M10.4 10.2v6.8M13.6 10.2v6.8" stroke={color} {...strokeProps} />
</Glyph>
);
}
import http from 'node:http';
import http from 'node:http';
import path from 'node:path';
import path from 'node:path';
/**
* Every path in a request is relative to the project the editor has open, and
* this is the only thing standing between that and the rest of the disk.
* `startsWith` alone does not do it — `/repo-backup` starts with `/repo` — so
* containment is decided on whole path segments, which is what `path.relative`
* reports: anything outside the root comes back leading with `..`.
*
* Returns null when the path escapes, so every caller can answer 400 the same
* way rather than repeating the reasoning.
*/
function resolveWithin(projectRoot, relative) {
if (!projectRoot) {
return null;
}
const absolute = path.resolve(projectRoot, relative || '');
const inside = path.relative(projectRoot, absolute);
if (inside.startsWith('..') || path.isAbsolute(inside)) {
return null;
}
return absolute;
}
function sendJson(response, status, payload) {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(payload));
}
function readJsonBody(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', chunk => {
body += chunk.toString();
});
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (error) {
reject(error);
}
});
request.on('error', reject);
});
}
async function exists(absolutePath) {
try {
await fs.access(absolutePath);
return true;
} catch (error) {
return false;
}
}
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 searchTerm = searchParams.get('term');
const searchTerm = searchParams.get('term');
const searchingFor = searchParams.get('type');
const searchingFor = searchParams.get('type');
if (!searchTerm) {
if (!searchTerm) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'missing term parameter' }));
return;
return sendJson(response, 400, { error: 'missing term parameter' });
}
}
if (searchingFor == 'files') {
if (searchingFor == 'files') {
}
}
}
}
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 absolute = path.resolve(projectRoot, wantedPath);
// convert the body from json string to an object
try {
const parsedBody = JSON.parse(body);
body = parsedBody.contents;
} catch (error) {
console.error(`Failed to parse request body: ${body}; error: ${error}`);
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid request body' }));
return resolve();
// Create, rename and delete all address a single entry and all answer the
// same two shapes, so they share a route and differ only by verb. Unlike
// `/file` they make no distinction between a file and a folder: the tree
// manipulates both, and which one it is comes from the body or the disk.
if (pathname === '/entry') {
if (method === 'POST') {
return createEntry(request, response, projectRoot, relative);
}
}
if (!absolute.startsWith(projectRoot)) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return resolve();
if (method === 'PATCH') {
return renameEntry(request, response, projectRoot, relative);
}
}
try {
await fs.writeFile(absolute, body, 'utf8');
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ path: wantedPath }));
} catch (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' }));
if (method === 'DELETE') {
return deleteEntry(response, projectRoot, relative);
}
}
resolve();
});
});
}
if (method == "PUT" && pathname === "/file") {
return writeFileContents(request, response, projectRoot, relative);
}
}
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 || '';
const absolute = resolveWithin(projectRoot, wantedPath);
if (wantedPath.includes('..')) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return;
if (!absolute) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
}
if (wantedPath.startsWith('/') && pathname === '/file') {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return;
if (pathname === '/file') {
return getFileContents(absolute, response, wantedPath);
}
}
const absolute = path.resolve(projectRoot, wantedPath);
return getDirectoryContents(absolute, response, wantedPath);
});
async function writeFileContents(request, response, projectRoot, relative) {
const wantedPath = relative || '';
const absolute = resolveWithin(projectRoot, wantedPath);
if (!absolute.startsWith(projectRoot)) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'invalid path parameter' }));
return;
if (!absolute) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
}
if (pathname === '/file') {
return getFileContents(absolute, response, wantedPath);
} else if (pathname === '/files') {
let path = wantedPath || '';
return getDirectoryContents(absolute, response, wantedPath);
let body;
try {
body = await readJsonBody(request);
} catch (error) {
console.error(`Failed to parse request body for: ${absolute}; error: ${error}`);
return sendJson(response, 400, { error: 'invalid request body' });
}
}
});
try {
await fs.writeFile(absolute, body.contents, 'utf8');
sendJson(response, 200, { path: wantedPath });
} catch (error) {
console.error(`Failed to write file: ${absolute}; error: ${error}`);
sendJson(response, 500, { error: 'failed to write file' });
}
}
/**
* Creating is allowed to create the folders above it too: VS Code's explorer
* takes `app/models/user.rb` in its new-file field and makes the whole path,
* and the tree here offers the same thing rather than one level at a time.
*
* A body carrying `copyFrom` asks for the same thing with contents: make an
* entry here, filled with what is already at that path.
*/
async function createEntry(request, response, projectRoot, relative) {
const absolute = resolveWithin(projectRoot, relative);
if (!absolute || !relative || absolute === path.resolve(projectRoot)) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
let body;
try {
body = await readJsonBody(request);
} catch (error) {
return sendJson(response, 400, { error: 'invalid request body' });
}
if (body.copyFrom) {
return copyEntry(response, projectRoot, absolute, body.copyFrom);
}
const isDirectory = body.type === 'directory';
if (await exists(absolute)) {
return sendJson(response, 409, { error: `${path.basename(absolute)} already exists` });
}
try {
await fs.mkdir(isDirectory ? absolute : path.dirname(absolute), { recursive: true });
if (!isDirectory) {
// `wx` rather than a plain write, because the existence check above is
// not the same instant as this: two racing creates must not let the
// loser blank the file the winner just made.
await fs.writeFile(absolute, '', { encoding: 'utf8', flag: 'wx' });
}
sendJson(response, 200, { path: relative, isDirectory });
} catch (error) {
console.error(`Failed to create entry: ${absolute}; error: ${error}`);
sendJson(response, 500, { error: 'failed to create entry' });
}
}
/**
* Pasting beside the original is the ordinary case, not a mistake, so a name
* that is already taken is answered the way the Finder answers it rather than
* refused the way a rename is: with a copy that says it is one.
*/
async function freeName(absolute, isDirectory) {
if (!(await exists(absolute))) {
return absolute;
}
const directory = path.dirname(absolute);
// Only a file has an extension worth preserving. Splitting `.github` or
// `my.folder` at the dot would move half the name to the end of the copy.
const extension = isDirectory ? '' : path.extname(absolute);
const base = path.basename(absolute, extension);
for (let attempt = 1; attempt <= 100; attempt++) {
const suffix = attempt === 1 ? ' copy' : ` copy ${attempt}`;
const candidate = path.join(directory, `${base}${suffix}${extension}`);
if (!(await exists(candidate))) {
return candidate;
}
}
return null;
}
async function copyEntry(response, projectRoot, absolute, from) {
const source = resolveWithin(projectRoot, from);
const root = path.resolve(projectRoot);
if (!source || source === root) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
let stats;
try {
stats = await fs.stat(source);
} catch (error) {
return sendJson(response, 404, { error: `${path.basename(source)} no longer exists` });
}
const destination = await freeName(absolute, stats.isDirectory());
if (!destination) {
return sendJson(response, 409, { error: `too many copies of ${path.basename(absolute)}` });
}
// Asked of the name the copy will actually take, not the one requested:
// pasting a folder beside itself asks for the path it already occupies, and
// that is the most ordinary paste there is once the name is made unique.
//
// What is left after that is a folder pasted into something it contains,
// where the copy would land inside the thing being copied and the walk would
// have no reason to ever stop.
if (destination === source || destination.startsWith(source + path.sep)) {
return sendJson(response, 400, { error: 'a folder cannot be copied into itself' });
}
try {
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.cp(source, destination, { recursive: true, errorOnExist: true, force: false });
sendJson(response, 200, { path: path.relative(projectRoot, destination), isDirectory: stats.isDirectory() });
} catch (error) {
console.error(`Failed to copy: ${source} -> ${destination}; error: ${error}`);
sendJson(response, 500, { error: 'failed to copy entry' });
}
}
/**
* Rename and move are the same operation seen from different angles — the tree
* only edits the last segment, but nothing here needs to care, so a `newPath`
* that lands somewhere else entirely works and drags the folders into place.
*/
async function renameEntry(request, response, projectRoot, relative) {
let body;
try {
body = await readJsonBody(request);
} catch (error) {
return sendJson(response, 400, { error: 'invalid request body' });
}
const from = resolveWithin(projectRoot, relative);
const to = resolveWithin(projectRoot, body.newPath);
const root = path.resolve(projectRoot);
if (!from || !to || !relative || !body.newPath || from === root || to === root) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
// On a case-insensitive volume — which is every default-formatted Mac —
// `README.md` already "exists" when the target is `readme.md`. But that is
// the very rename being asked for, and refusing it would make capitalisation
// the one edit the tree cannot make.
const isCaseOnlyRename = from.toLowerCase() === to.toLowerCase();
if (!isCaseOnlyRename && await exists(to)) {
return sendJson(response, 409, { error: `${path.basename(to)} already exists` });
}
try {
await fs.mkdir(path.dirname(to), { recursive: true });
await fs.rename(from, to);
sendJson(response, 200, { path: body.newPath });
} catch (error) {
console.error(`Failed to rename: ${from} -> ${to}; error: ${error}`);
sendJson(response, 500, { error: 'failed to rename entry' });
}
}
/**
* There is no trash to move to from Node, so this is permanent — which is why
* the tree asks before calling it, rather than offering an undo it cannot honour.
*/
async function deleteEntry(response, projectRoot, relative) {
const absolute = resolveWithin(projectRoot, relative);
if (!absolute || !relative || absolute === path.resolve(projectRoot)) {
return sendJson(response, 400, { error: 'invalid path parameter' });
}
try {
await fs.rm(absolute, { recursive: true, force: true });
sendJson(response, 200, { path: relative });
} catch (error) {
console.error(`Failed to delete: ${absolute}; error: ${error}`);
sendJson(response, 500, { error: 'failed to delete entry' });
}
}
// The editor's shell, on the same port: `ws://127.0.0.1:4001/terminal`.
// The editor's shell, on the same port: `ws://127.0.0.1:4001/terminal`.
attachTerminal(server);
attachTerminal(server);