EDITOR_OPTIONS,
EDITOR_OPTIONS,
EXTRA_LANGUAGES,
EXTRA_LANGUAGES,
MONACO_CDN,
MONACO_CDN,
SHIKI_CDN,
SHIKI_LANGS,
SHIKI_LANGS,
SHIKI_LANG_ALIAS,
SHIKI_LANG_ALIAS,
SHIKI_MONACO_CDN,
THEMES,
THEMES,
monacoTheme,
monacoTheme,
} from './monacoTheme';
} from './monacoTheme';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {StyleSheet, useColorScheme} from 'react-native';
import {StyleSheet, useColorScheme} from 'react-native';
import {CDN_SOURCES} from './code/vendor';
import {WebView} from 'react-native-webview';
import {WebView} from 'react-native-webview';
import {fileSystem} from './fileSystem';
import {useTheme} from './theme';
import {useTheme} from './theme';
import {useThemedStyles} from './theme';
import {useThemedStyles} from './theme';
* build resolves `CodeEditor.web.jsx` and uses Monaco directly, the native
* build resolves `CodeEditor.web.jsx` and uses Monaco directly, the native
* build gets this file and runs Monaco in the only DOM the app has.
* build gets this file and runs Monaco in the only DOM the app has.
*
*
* The page is served as an HTML string with the CDN as its base URL, so the
* The page is served as an HTML string whose base URL is wherever Monaco is
* loaded from — the file server when it is up, the CDN otherwise — so the
* loader script counts as same-origin and WebKit doesn't refuse it.
* loader script counts as same-origin and WebKit doesn't refuse it.
*
* Everything that crosses the bridge is a message. Native -> page is
* `injectJavaScript` of a `window.*` call; page -> native is `postMessage`
* with a `type`. The ref exposes the calls the page wants as methods.
*/
*/
export function CodeEditor({
export const CodeEditor = forwardRef(function CodeEditor(
{
value = '',
value = '',
language,
language,
path,
path,
projectRoot,
sources = CDN_SOURCES,
appKeys,
gitChanges,
diffOriginal = null,
onChange,
onChange,
onSave,
onSave,
onSearch,
onCommand,
onOpenFile,
onCursor,
onDiagnostics,
onActions,
onLoadError,
onFocus,
style,
style,
}) {
},
ref,
) {
const webView = useRef(null);
const webView = useRef(null);
const [loaded, setLoaded] = useState(false);
const [loaded, setLoaded] = useState(false);
const colors = useTheme();
const colors = useTheme();
const scheme = useColorScheme() ?? 'light';
const scheme = useColorScheme() ?? 'light';
const styles = useThemedStyles(createStyles);
const styles = useThemedStyles(createStyles);
const inject = useCallback(script => {
webView.current?.injectJavaScript(`${script} true;`);
}, []);
const call = useCallback(
(name, ...args) => {
inject(`window.${name}(${args.map(argument => jsString(argument)).join(', ')});`);
},
[inject],
);
// Monaco owns the buffers once it is up, so the document is built exactly
// Monaco owns the buffers once it is up, so the document is built exactly
// once. A `source` that changed identity on re-render would reload the frame
// once. A `source` that changed identity on re-render would reload the frame
// and throw away the user's edits along with the undo stacks.
// and throw away the user's edits along with the undo stacks.
const source = useRef(null);
const source = useRef(null);
source.current ??= {
source.current ??= {
html: editorHtml({path, value, language}),
baseUrl: `${MONACO_CDN}/`,
html: editorHtml({path, value, language, projectRoot}, sources),
baseUrl: `${sources.monaco}/`,
};
};
// Monaco owns the buffers, so a new `value` is pushed in as an edit rather
// Monaco owns the buffers, so a new `value` is pushed in as an edit rather
// alone can't tell "the user typed" from "we are on a different file now".
// alone can't tell "the user typed" from "we are on a different file now".
const applied = useRef({path, value, language});
const applied = useRef({path, value, language});
useEffect(() => {
if (!loaded) {
return;
}
call('setProject', projectRoot ?? null);
}, [loaded, projectRoot, call]);
// Path and value go over together, in one message, for the same reason.
// Path and value go over together, in one message, for the same reason.
// Pushing the value first would write the incoming file's text into the
// Pushing the value first would write the incoming file's text into the
// outgoing file's buffer before the editor ever swapped models. `language`
// outgoing file's buffer before the editor ever swapped models.
// rides along; without it the web side resolves the mode from the path
// against Monaco's own extension registry — `.md` is only `markdown`
// because Monaco says so.
useEffect(() => {
useEffect(() => {
if (
if (
!loaded ||
!loaded ||
}
}
applied.current = {path, value, language};
applied.current = {path, value, language};
webView.current?.injectJavaScript(
`window.setFile(${JSON.stringify({path, value, language})}); true;`,
);
call('setFile', {path, value, language});
}, [loaded, path, value, language]);
}, [loaded, path, value, language, call]);
// The palette arrives from a fetch, so the theme is pushed in rather than
// The palette arrives from a fetch, so the theme is pushed in rather than
// baked into the document.
// baked into the document.
return;
return;
}
}
webView.current?.injectJavaScript(
`window.setTheme(${JSON.stringify(monacoTheme(colors, scheme))}); true;`,
call('setTheme', monacoTheme(colors, scheme));
}, [loaded, colors, scheme, call]);
useEffect(() => {
if (!loaded || !appKeys) {
return;
}
call('setAppKeys', appKeys);
}, [loaded, appKeys, call]);
useEffect(() => {
if (!loaded || !path) {
return;
}
call('setGitChanges', path, gitChanges ?? []);
}, [loaded, path, gitChanges, call]);
useEffect(() => {
if (!loaded) {
return;
}
if (diffOriginal === null || diffOriginal === undefined) {
call('hideDiff');
} else {
call('showDiff', diffOriginal);
}
}, [loaded, diffOriginal, call]);
// Language servers. The page asks for one per language it meets; the socket
// is dialled from here because the page is a secure context and may not open
// a plain `ws://` itself. Messages are relayed verbatim in both directions.
const servers = useRef(new Map());
const closeServers = useCallback(() => {
for (const socket of servers.current.values()) {
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
socket.close();
}
servers.current.clear();
}, []);
const openServer = useCallback(
lang => {
if (servers.current.has(lang) || !projectRoot) {
return;
}
let socket;
try {
socket = new WebSocket(fileSystem.lspUrl(projectRoot, lang));
} catch (error) {
call('lspUnavailable', lang);
return;
}
servers.current.set(lang, socket);
socket.onmessage = event => {
const message = JSON.parse(event.data);
if (message.type === 'ready') {
call('lspReady', lang, message.server);
} else if (message.type === 'message') {
call('lspReceive', lang, message.body);
} else if (message.type === 'unavailable' || message.type === 'error' || message.type === 'exit') {
call('lspUnavailable', lang);
}
};
socket.onerror = () => call('lspUnavailable', lang);
socket.onclose = () => {
if (servers.current.get(lang) === socket) {
servers.current.delete(lang);
}
};
},
[projectRoot, call],
);
);
}, [loaded, colors, scheme]);
// A new project means new servers: their roots are fixed when spawned.
useEffect(() => {
closeServers();
if (loaded) {
call('resetLanguageServers');
}
}, [projectRoot, loaded, closeServers, call]);
useEffect(() => () => closeServers(), [closeServers]);
const pendingActions = useRef([]);
useImperativeHandle(
ref,
() => ({
revealPosition: (line, column = 1) => call('revealPosition', line, column),
requestSave: (format = false) => call('requestSave', format),
runAction: id => call('runAction', id),
focus: () => call('focusEditor'),
find: term => call('openFind', term ?? ''),
listActions: () =>
new Promise(resolve => {
pendingActions.current.push(resolve);
call('listActions');
}),
reload: () => webView.current?.reload(),
}),
[call],
);
const onMessage = useCallback(
const onMessage = useCallback(
event => {
event => {
const message = JSON.parse(event.nativeEvent.data);
const message = JSON.parse(event.nativeEvent.data);
if (message.type === 'change') {
switch (message.type) {
case 'ready':
setLoaded(true);
break;
case 'loadError':
onLoadError?.(message);
break;
case 'change':
applied.current = {path: message.path, value: message.value, language};
applied.current = {path: message.path, value: message.value, language};
onChange?.(message.value);
onChange?.(message.value, message.path);
break;
// The buffer the editor is asking us to write is the one it just
// handed over, so it counts as agreed on — otherwise a save
// mid-keystroke would come back as a `value` prop and get injected
// straight back at it.
case 'save':
applied.current = {path: message.path, value: message.value, language};
onSave?.(message.value, message.path, Boolean(message.blur));
break;
// A shortcut that belongs to the app rather than to the editor —
// Monaco is the only thing that sees the keystroke while the WebView
// holds focus, so it hands it back.
case 'key':
onCommand?.(message);
break;
case 'focus':
onFocus?.();
break;
case 'openFile':
onOpenFile?.(message);
break;
case 'cursor':
onCursor?.(message);
break;
case 'diagnostics':
onDiagnostics?.(message);
break;
case 'actions': {
const waiting = pendingActions.current.splice(0);
waiting.forEach(resolve => resolve(message.actions));
onActions?.(message.actions);
break;
}
}
// The buffer the editor is asking us to write is the one it just handed
// over, so it counts as agreed on — otherwise a save mid-keystroke would
// come back as a `value` prop and get injected straight back at it.
if (message.type === 'save') {
applied.current = {path: message.path, value: message.value, language};
onSave?.(message.value);
case 'lspOpen':
openServer(message.language);
break;
case 'lsp': {
const socket = servers.current.get(message.language);
if (socket?.readyState === 1) {
socket.send(JSON.stringify({type: 'message', body: message.body}));
}
break;
}
}
// Cmd+P is the app's, not the editor's — Monaco is the only thing that
// sees the keystroke while the WebView holds focus, so it hands it back.
if (message.type === 'search') {
onSearch?.();
default:
break;
}
}
},
},
[language, onChange, onSave, onSearch],
[language, onChange, onSave, onCommand, onOpenFile, onCursor, onDiagnostics, onActions, onLoadError, onFocus, openServer],
);
);
return (
return (
// are needed: the style covers the old renderer, `opaque` the new one.
// are needed: the style covers the old renderer, `opaque` the new one.
opaque={false}
opaque={false}
backgroundColor="transparent"
backgroundColor="transparent"
onLoadEnd={() => setLoaded(true)}
onMessage={onMessage}
onMessage={onMessage}
// Nothing in the editor should ever navigate the app away from itself.
// Nothing in the editor should ever navigate the app away from itself.
onShouldStartLoadWithRequest={request =>
onShouldStartLoadWithRequest={request =>
request.navigationType !== 'click'
request.navigationType !== 'click'
}
}
/>
/>
);
);
});
/**
* `injectJavaScript` takes source, not data. JSON is very nearly a subset of
* JS, and the two line separators are the whole of the difference: JSON allows
* them raw inside a string, JS ends the line on them.
*/
function jsString(value) {
return JSON.stringify(value === undefined ? null : value)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
}
function editorHtml(file) {
export function editorHtml(file, sources) {
const importMap = sources.importMap
? `<script type="importmap">${JSON.stringify(sources.importMap)}</script>`
: '';
return `<!doctype html>
return `<!doctype html>
<html>
<html>
<head>
<head>
<meta charset="utf-8" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
${importMap}
<style>
<style>
/* A transparent WKWebView still paints whatever the document paints,
/* A transparent WKWebView still paints whatever the document paints,
and the default UA background is white. */
and the default UA background is white. */
html, body, #container {
html, body, #container, #diff {
margin: 0; padding: 0; height: 100%; width: 100%; overflow: hidden;
margin: 0; padding: 0; height: 100%; width: 100%; overflow: hidden;
background: transparent;
background: transparent;
}
}
#diff { display: none; }
.monaco-editor, .monaco-editor-background { background: transparent; }
.monaco-editor, .monaco-editor-background, .monaco-diff-editor { background: transparent; }
/* WebKit rings the focused element in the system accent colour. Monaco
/* WebKit rings the focused element in the system accent colour. Monaco
keeps focus on a hidden textarea, so the ring only ever traces the
keeps focus on a hidden textarea, so the ring only ever traces the
editor's outer edge — it reads as a stray blue border. */
editor's outer edge — it reads as a stray blue border. */
:focus, :focus-visible { outline: none; }
:focus, :focus-visible { outline: none; }
.monaco-editor .overflow-guard { outline: none; }
.monaco-editor .overflow-guard { outline: none; }
/* Git changes in the gutter, the way VS Code draws them. */
.git-added, .git-modified, .git-deleted {
width: 3px !important; margin-left: 6px; border-radius: 1px;
}
.git-added { background: #2ea043; }
.git-modified { background: #0078d4; }
.git-deleted {
background: transparent;
border-bottom: 3px solid #f85149; height: 0 !important;
top: -1px;
}
#status {
position: absolute; right: 10px; bottom: 6px; font: 11px -apple-system, sans-serif;
opacity: 0.6; pointer-events: none;
}
</style>
</style>
</head>
</head>
<body>
<body>
<div id="container"></div>
<div id="container"></div>
<script src="${MONACO_CDN}/vs/loader.js"></script>
<div id="diff"></div>
<script>
<script>
var SOURCES = ${JSON.stringify(sources)};
var CDN = ${JSON.stringify(MONACO_CDN)};
var editor = null;
var editor = null;
var diffEditor = null;
var theme = null;
var theme = null;
var pendingFile = null;
var pendingFile = null;
var pendingOptions = null;
var projectRoot = ${JSON.stringify(file.projectRoot ?? null)};
var monacoBase = SOURCES.monaco;
function post(message) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
}
// One model per file, keyed by path. Sharing a model across files shares
// One model per file, keyed by path. Sharing a model across files shares
// its undo stack with them: every switch pushed the new text onto the one
// its undo stack with them: every switch pushed the new text onto the one
return currentPath === UNTITLED ? null : currentPath;
return currentPath === UNTITLED ? null : currentPath;
}
}
// Absolute on disk, so a language server and the editor agree on what a
// file is called, and so a definition in another file resolves to a path
// the native side can open.
function absolutePath(path) {
if (!projectRoot) {
return '/' + path;
}
return projectRoot.replace(/\\/$/, '') + '/' + path;
}
function fileUri(path) {
return monaco.Uri.file(absolutePath(path));
}
function relativePath(uri) {
var fsPath = uri.path || uri.fsPath || String(uri);
if (projectRoot && fsPath.indexOf(projectRoot) === 0) {
return fsPath.slice(projectRoot.length).replace(/^\\//, '');
}
return fsPath.replace(/^\\//, '');
}
// The two VS Code theme files, whole. Shiki reads its grammars off a CDN,
// The two VS Code theme files, whole. Shiki reads its grammars off a CDN
// but a theme is the user's own file — it travels with the document.
// or the file server, but a theme is the user's own file — it travels
// with the document.
var THEMES = ${JSON.stringify(THEMES)};
var THEMES = ${JSON.stringify(THEMES)};
// Each theme run through textmateThemeToMonacoTheme, once Shiki is up.
// Each theme run through textmateThemeToMonacoTheme, once Shiki is up.
// Until then Monaco is still tokenizing with Monarch, whose token names
// Until then Monaco is still tokenizing with Monarch, whose token names
// no TextMate rule would have matched anyway.
// no TextMate rule would have matched anyway.
var converted = null;
var converted = null;
// Monaco's language services run in workers. Pulled straight off a CDN
// Monaco's language services run in workers. Pulled straight off another
// they would be cross-origin, so each worker is booted from a data: URL
// origin they would be cross-origin, so each worker is booted from a
// that imports the real script instead.
// data: URL that imports the real script instead.
window.MonacoEnvironment = {
window.MonacoEnvironment = {
getWorkerUrl: function () {
getWorkerUrl: function () {
return 'data:text/javascript;charset=utf-8,' + encodeURIComponent(
return 'data:text/javascript;charset=utf-8,' + encodeURIComponent(
"self.MonacoEnvironment = { baseUrl: '${MONACO_CDN}/' };" +
"self.MonacoEnvironment = { baseUrl: '" + monacoBase + "/' };" +
"importScripts('${MONACO_CDN}/vs/base/worker/workerMain.js');"
"importScripts('" + monacoBase + "/vs/base/worker/workerMain.js');"
);
);
},
},
};
};
// The theme is defined twice over its life: once from the file alone, so
// The theme is defined twice over its life: once from the file alone, so
// the editor is transparent and the right shade from the first frame, and
// the editor is transparent and the right shade from the first frame, and
// again once Shiki has converted the 211 TextMate rules the file carries.
// again once Shiki has converted the TextMate rules the file carries.
// Either way the app's own colours go on last.
// Either way the app's own colours go on last.
function applyTheme() {
function applyTheme() {
if (!window.monaco || !theme) {
if (!window.monaco || !theme) {
var next = converted && converted[theme.name];
var next = converted && converted[theme.name];
if (!next) {
if (!next) {
// The file's colours without its scopes: Monarch still doing the
// tokenizing, but against the right surface.
next = {
next = {
base: file.type === 'dark' ? 'vs-dark' : 'vs',
base: file.type === 'dark' ? 'vs-dark' : 'vs',
inherit: true,
inherit: true,
}
}
// Shiki hands Monaco VS Code's own tokenizer, so the theme's scopes
// Shiki hands Monaco VS Code's own tokenizer, so the theme's scopes
// finally have scopes to match. It is a CDN import over WASM: slower to
// finally have scopes to match. It is a WASM-backed ESM import: slower
// arrive than the editor is to boot, and allowed to fail — a miss leaves
// to arrive than the editor is to boot, and allowed to fail — a miss
// Monarch in place, which is what the editor came with.
// leaves Monarch in place, which is what the editor came with.
async function startShiki() {
async function startShiki() {
try {
try {
var shiki = await import('${SHIKI_CDN}');
var bridge = await import('${SHIKI_MONACO_CDN}');
var shiki = await import(SOURCES.shiki);
var bridge = await import(SOURCES.shikiMonaco);
var names = Object.keys(THEMES);
var names = Object.keys(THEMES);
var highlighter = await shiki.createHighlighter({
var highlighter = await shiki.createHighlighter({
langAlias: ${JSON.stringify(SHIKI_LANG_ALIAS)},
langAlias: ${JSON.stringify(SHIKI_LANG_ALIAS)},
});
});
// shikiToMonaco colours a token by looking its scope up by the
// colour it resolved, so the rules Monaco holds have to be the ones
// it derived — same function over the same themes. applyTheme then
// layers the app's colours back over the result.
converted = {};
converted = {};
names.forEach(function (name) {
names.forEach(function (name) {
converted[name] = bridge.textmateThemeToMonacoTheme(
converted[name] = bridge.textmateThemeToMonacoTheme(
bridge.shikiToMonaco(highlighter, monaco);
bridge.shikiToMonaco(highlighter, monaco);
applyTheme();
applyTheme();
} catch (error) {
} catch (error) {
post({type: 'log', text: 'shiki unavailable: ' + (error && error.message)});
}
}
}
}
var model = models[path];
var model = models[path];
if (!model) {
if (!model) {
var uri = path === UNTITLED ? undefined : monaco.Uri.file(path);
var uri = path === UNTITLED ? undefined : fileUri(path);
model =
model =
(uri && monaco.editor.getModel(uri)) ||
(uri && monaco.editor.getModel(uri)) ||
uri,
uri,
);
);
if (pendingOptions) {
model.updateOptions({tabSize: pendingOptions.tabSize, insertSpaces: pendingOptions.insertSpaces});
}
models[path] = model;
models[path] = model;
if (path !== UNTITLED) {
lspDidOpen(model);
}
}
}
return model;
return model;
// A file that has moved on disk since it was last open. Anything else
// A file that has moved on disk since it was last open. Anything else
// is the editor's own text coming back around as a prop.
// is the editor's own text coming back around as a prop.
if (model.getValue() !== next.value) {
if (model.getValue() !== next.value) {
model.setValue(next.value);
// pushEditOperations rather than setValue: the undo stack survives,
// and the cursor stays roughly where it was.
var full = model.getFullModelRange();
model.pushEditOperations([], [{range: full, text: next.value}], function () { return null; });
}
}
var id = next.language || languageForPath(path);
var id = next.language || languageForPath(path);
if (switching) {
if (switching) {
currentPath = path;
currentPath = path;
if (diffEditor && diffVisible) {
hideDiff();
}
editor.setModel(model);
editor.setModel(model);
if (viewStates[path]) {
if (viewStates[path]) {
editor.restoreViewState(viewStates[path]);
editor.restoreViewState(viewStates[path]);
}
}
applyGitDecorations(path);
postDiagnosticsFor(model);
}
}
};
};
function post(message) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(message));
// All buffers belong to the project; a new project means new URIs, new
// servers and no leftover models.
window.setProject = function (root) {
if (root === projectRoot) {
return;
}
}
}
require.config({paths: {vs: '${MONACO_CDN}/vs'}});
require(['vs/editor/editor.main'], function () {
// Registered before Shiki, which pairs its grammars against whichever
// modes exist at the moment it runs.
${JSON.stringify(EXTRA_LANGUAGES)}.forEach(function (language) {
monaco.languages.register(language);
projectRoot = root;
if (!window.monaco) {
return;
}
Object.keys(models).forEach(function (path) {
if (models[path] !== editor.getModel()) {
models[path].dispose();
}
});
});
// No model of its own: every buffer this editor shows is one setFile
// created against a URI, and an implicit one would only be orphaned by
// the first switch.
editor = monaco.editor.create(document.getElementById('container'), Object.assign(
${JSON.stringify(EDITOR_OPTIONS)},
{model: null}
));
var current = editor && editor.getModel();
models = {};
viewStates = {};
gitChanges = {};
applyTheme();
if (current) {
current.dispose();
currentPath = null;
}
};
window.setOptions = function (options) {
pendingOptions = options;
if (!editor) {
return;
}
// Whatever the native side asked for while the loader was still
// running, or the file the document was built around if it asked for
// nothing.
window.setFile(pendingFile || ${JSON.stringify(file)});
pendingFile = null;
// Bound after the first file, so opening one is not itself a change.
// The listener is the editor's rather than the model's, so it follows
// whichever model is attached.
editor.onDidChangeModelContent(function () {
post({type: 'change', path: filePath(), value: editor.getValue()});
editor.updateOptions(options);
if (diffEditor) {
diffEditor.updateOptions(options);
}
monaco.editor.getModels().forEach(function (model) {
model.updateOptions({tabSize: options.tabSize, insertSpaces: options.insertSpaces});
});
});
};
window.revealPosition = function (line, column) {
if (!editor) {
return;
}
var position = {lineNumber: Math.max(1, line || 1), column: Math.max(1, column || 1)};
editor.setPosition(position);
editor.revealPositionInCenter(position, 0);
editor.focus();
};
window.focusEditor = function () {
if (editor) {
editor.focus();
}
};
window.openFind = function (term) {
if (!editor) {
return;
}
editor.focus();
var action = editor.getAction('actions.find');
if (action) {
action.run();
}
};
window.runAction = function (id) {
if (!editor) {
return;
}
editor.focus();
var action = editor.getAction(id);
if (action) {
action.run();
} else {
editor.trigger('app', id, null);
}
};
window.listActions = function () {
if (!editor) {
post({type: 'actions', actions: []});
return;
}
post({
type: 'actions',
actions: editor.getSupportedActions().map(function (action) {
return {id: action.id, label: action.label, alias: action.alias};
}),
});
};
// Format first when asked, then hand the buffer over. A language with no
// formatter is common, so the format is allowed to do nothing — and it
// is given a deadline, because a hung language server must not swallow
// the save.
window.requestSave = function (format) {
if (!editor) {
return;
}
// CtrlCmd is Cmd on macOS. Registering the binding with Monaco rather
// than on the document is also what keeps WebKit from opening its own
// save sheet — a handled keybinding never reaches the browser default.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
var done = false;
function finish() {
if (done) {
return;
}
done = true;
post({type: 'save', path: filePath(), value: editor.getValue()});
post({type: 'save', path: filePath(), value: editor.getValue()});
}
if (!format) {
finish();
return;
}
var action = editor.getAction('editor.action.formatDocument');
if (!action) {
finish();
return;
}
var timer = setTimeout(finish, 1500);
Promise.resolve()
.then(function () { return action.run(); })
.catch(function () {})
.then(function () { clearTimeout(timer); finish(); });
};
// ---- Git gutter --------------------------------------------------------
var gitChanges = {};
var gitDecorationIds = {};
window.setGitChanges = function (path, changes) {
gitChanges[path] = changes || [];
if (path === currentPath) {
applyGitDecorations(path);
}
};
function applyGitDecorations(path) {
var model = models[path];
if (!model || model.isDisposed()) {
return;
}
var decorations = (gitChanges[path] || []).map(function (change) {
var start = Math.max(1, change.startLine);
var end = Math.max(start, change.endLine);
return {
range: new monaco.Range(start, 1, end, 1),
options: {
isWholeLine: false,
linesDecorationsClassName: 'git-' + change.kind,
overviewRuler: {
color: change.kind === 'added' ? '#2ea043' : change.kind === 'deleted' ? '#f85149' : '#0078d4',
position: monaco.editor.OverviewRulerLane.Left,
},
},
};
});
});
// Same reasoning as save: bound here so the keystroke never reaches
// WebKit, which would otherwise open its own print dialog.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyP, function () {
post({type: 'search'});
gitDecorationIds[path] = model.deltaDecorations(gitDecorationIds[path] || [], decorations);
}
// ---- Diff view ---------------------------------------------------------
var diffVisible = false;
var diffOriginalModel = null;
window.showDiff = function (original) {
if (!editor) {
return;
}
var model = editor.getModel();
if (!model) {
return;
}
if (!diffEditor) {
diffEditor = monaco.editor.createDiffEditor(document.getElementById('diff'), Object.assign(
{},
${JSON.stringify(EDITOR_OPTIONS)},
pendingOptions || {},
{automaticLayout: true, renderSideBySide: true, originalEditable: false, readOnly: false, renderOverviewRuler: false}
));
}
if (diffOriginalModel) {
diffOriginalModel.dispose();
}
diffOriginalModel = monaco.editor.createModel(original || '', model.getLanguageId());
diffEditor.setModel({original: diffOriginalModel, modified: model});
document.getElementById('container').style.display = 'none';
document.getElementById('diff').style.display = 'block';
diffVisible = true;
};
function hideDiff() {
if (!diffVisible) {
return;
}
document.getElementById('diff').style.display = 'none';
document.getElementById('container').style.display = 'block';
diffVisible = false;
if (diffEditor) {
diffEditor.setModel(null);
}
if (diffOriginalModel) {
diffOriginalModel.dispose();
diffOriginalModel = null;
}
if (editor) {
editor.layout();
editor.focus();
}
}
window.hideDiff = hideDiff;
// ---- App shortcuts -----------------------------------------------------
// The app's own bindings, as [{key, metaKey, ctrlKey, altKey, shiftKey}].
// Any of them pressed while the editor has focus is handed back rather
// than swallowed. Only combinations with a modifier are taken on the
// way down — a bare Escape belongs to whatever Monaco widget is open,
// and is only handed back when nothing here wanted it.
var appKeys = [];
window.setAppKeys = function (keys) {
appKeys = keys || [];
};
function keyMatches(binding, event) {
return String(binding.key).toLowerCase() === String(event.key).toLowerCase() &&
Boolean(binding.metaKey) === event.metaKey &&
Boolean(binding.ctrlKey) === event.ctrlKey &&
Boolean(binding.altKey) === event.altKey &&
Boolean(binding.shiftKey) === event.shiftKey;
}
function keyMessage(event) {
return {
type: 'key',
key: event.key,
code: event.code,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
};
}
document.addEventListener('keydown', function (event) {
var hasModifier = event.metaKey || event.ctrlKey || event.altKey;
if (!hasModifier) {
return;
}
for (var index = 0; index < appKeys.length; index++) {
if (keyMatches(appKeys[index], event)) {
event.preventDefault();
event.stopPropagation();
post(keyMessage(event));
return;
}
}
}, true);
document.addEventListener('keydown', function (event) {
if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) {
return;
}
for (var index = 0; index < appKeys.length; index++) {
if (keyMatches(appKeys[index], event)) {
post(keyMessage(event));
return;
}
}
}, false);
// ---- Diagnostics -------------------------------------------------------
// Monaco's own workers (TypeScript, JSON, CSS) and the language servers
// both end up as markers; whichever wrote them, the native side gets
// the list for the Problems panel.
function postDiagnosticsFor(model) {
if (!model || model.isDisposed() || !model.uri || model.uri.scheme !== 'file') {
return;
}
var markers = monaco.editor.getModelMarkers({resource: model.uri});
post({
type: 'diagnostics',
path: relativePath(model.uri),
diagnostics: markers.map(function (marker) {
return {
line: marker.startLineNumber,
column: marker.startColumn,
endLine: marker.endLineNumber,
endColumn: marker.endColumn,
message: marker.message,
severity: marker.severity === monaco.MarkerSeverity.Error ? 'error'
: marker.severity === monaco.MarkerSeverity.Warning ? 'warning'
: marker.severity === monaco.MarkerSeverity.Info ? 'info' : 'hint',
source: marker.source || marker.owner,
};
}),
});
});
}
// Clicking away — into the file tree, or out of the window — is a save.
// The native side drops the write if nothing has changed, so this is
// free when the editor is only being tabbed through.
editor.onDidBlurEditorText(function () {
post({type: 'save', path: filePath(), value: editor.getValue()});
// ---- Language servers --------------------------------------------------
// Monaco ships its own intelligence for these; a server is only asked
// for when it has none.
var BUILTIN = {typescript: 1, javascript: 1, css: 1, scss: 1, less: 1, html: 1, json: 1, plaintext: 1};
var clients = {};
function ensureClient(language) {
if (!language || BUILTIN[language] || clients[language] || !projectRoot) {
return;
}
clients[language] = {state: 'opening', nextId: 1, pending: {}, versions: {}, disposables: []};
post({type: 'lspOpen', language: language});
}
window.resetLanguageServers = function () {
Object.keys(clients).forEach(function (language) {
clients[language].disposables.forEach(function (disposable) { disposable.dispose(); });
});
clients = {};
if (window.monaco) {
monaco.editor.getModels().forEach(function (model) {
monaco.editor.setModelMarkers(model, 'lsp', []);
});
}
};
function lspSend(language, message) {
post({type: 'lsp', language: language, body: JSON.stringify(message)});
}
function lspRequest(language, method, params) {
var client = clients[language];
if (!client) {
return Promise.reject(new Error('no client'));
}
var id = client.nextId++;
return new Promise(function (resolve, reject) {
client.pending[id] = {resolve: resolve, reject: reject};
lspSend(language, {jsonrpc: '2.0', id: id, method: method, params: params});
});
}
function lspNotify(language, method, params) {
lspSend(language, {jsonrpc: '2.0', method: method, params: params});
}
window.lspReady = function (language, server) {
var client = clients[language];
if (!client) {
return;
}
client.state = 'initializing';
client.server = server;
lspRequest(language, 'initialize', {
processId: null,
clientInfo: {name: 'all-in-one', version: '1.0'},
rootUri: fileUri('').toString(),
rootPath: projectRoot,
workspaceFolders: [{uri: fileUri('').toString(), name: projectRoot.split('/').pop()}],
capabilities: {
workspace: {workspaceFolders: true, configuration: false, applyEdit: false},
textDocument: {
synchronization: {didSave: true, willSave: false},
hover: {contentFormat: ['markdown', 'plaintext']},
completion: {completionItem: {snippetSupport: true, documentationFormat: ['markdown', 'plaintext']}, contextSupport: true},
signatureHelp: {signatureInformation: {documentationFormat: ['markdown', 'plaintext']}},
definition: {},
references: {},
documentSymbol: {hierarchicalDocumentSymbolSupport: true},
formatting: {},
rangeFormatting: {},
rename: {prepareSupport: false},
publishDiagnostics: {relatedInformation: false},
codeAction: {},
},
},
initializationOptions: {},
}).then(function (result) {
client.capabilities = (result && result.capabilities) || {};
client.state = 'ready';
lspNotify(language, 'initialized', {});
registerProviders(language);
monaco.editor.getModels().forEach(function (model) {
if (model.getLanguageId() === language && model.uri.scheme === 'file') {
lspDidOpen(model);
}
});
}).catch(function () {
client.state = 'unavailable';
});
};
window.lspUnavailable = function (language) {
if (clients[language]) {
clients[language].state = 'unavailable';
} else {
clients[language] = {state: 'unavailable', nextId: 1, pending: {}, versions: {}, disposables: []};
}
};
window.lspReceive = function (language, body) {
var client = clients[language];
if (!client) {
return;
}
var message;
try {
message = JSON.parse(body);
} catch (error) {
return;
}
if (message.id != null && client.pending[message.id]) {
var waiting = client.pending[message.id];
delete client.pending[message.id];
if (message.error) {
waiting.reject(message.error);
} else {
waiting.resolve(message.result);
}
return;
}
if (message.method === 'textDocument/publishDiagnostics') {
applyLspDiagnostics(message.params);
return;
}
// Requests from the server we have no answer to get an empty one, so
// it does not sit waiting.
if (message.method && message.id != null) {
var result = null;
if (message.method === 'workspace/configuration') {
result = (message.params.items || []).map(function () { return null; });
}
if (message.method === 'client/registerCapability' || message.method === 'client/unregisterCapability' || message.method === 'window/workDoneProgress/create') {
result = null;
}
if (message.method === 'workspace/workspaceFolders') {
result = [{uri: fileUri('').toString(), name: 'project'}];
}
lspSend(language, {jsonrpc: '2.0', id: message.id, result: result});
}
};
function lspDidOpen(model) {
var language = model.getLanguageId();
ensureClient(language);
var client = clients[language];
if (!client || client.state !== 'ready') {
return;
}
var uri = model.uri.toString();
if (client.versions[uri] != null) {
return;
}
client.versions[uri] = 1;
lspNotify(language, 'textDocument/didOpen', {
textDocument: {uri: uri, languageId: language, version: 1, text: model.getValue()},
});
}
function lspDidChange(model) {
var language = model.getLanguageId();
var client = clients[language];
if (!client || client.state !== 'ready') {
return;
}
var uri = model.uri.toString();
if (client.versions[uri] == null) {
lspDidOpen(model);
return;
}
client.versions[uri]++;
lspNotify(language, 'textDocument/didChange', {
textDocument: {uri: uri, version: client.versions[uri]},
contentChanges: [{text: model.getValue()}],
});
});
}
function lspDidSave(model) {
var language = model.getLanguageId();
var client = clients[language];
if (!client || client.state !== 'ready' || client.versions[model.uri.toString()] == null) {
return;
}
lspNotify(language, 'textDocument/didSave', {textDocument: {uri: model.uri.toString()}, text: model.getValue()});
}
var LSP_SEVERITY = {1: 8, 2: 4, 3: 2, 4: 1};
function applyLspDiagnostics(params) {
var model = monaco.editor.getModel(monaco.Uri.parse(params.uri));
if (!model) {
return;
}
monaco.editor.setModelMarkers(model, 'lsp', (params.diagnostics || []).map(function (diagnostic) {
return {
startLineNumber: diagnostic.range.start.line + 1,
startColumn: diagnostic.range.start.character + 1,
endLineNumber: diagnostic.range.end.line + 1,
endColumn: diagnostic.range.end.character + 1,
message: diagnostic.message,
severity: LSP_SEVERITY[diagnostic.severity] || 8,
source: diagnostic.source,
code: diagnostic.code != null ? String(diagnostic.code) : undefined,
};
}));
}
function toLspPosition(position) {
return {line: position.lineNumber - 1, character: position.column - 1};
}
function toRange(range) {
return new monaco.Range(range.start.line + 1, range.start.character + 1, range.end.line + 1, range.end.character + 1);
}
function toLocation(location) {
var uri = location.targetUri || location.uri;
var range = location.targetSelectionRange || location.targetRange || location.range;
return {uri: monaco.Uri.parse(uri), range: toRange(range)};
}
function textDocument(model) {
return {uri: model.uri.toString()};
}
function markdown(contents) {
if (!contents) {
return [];
}
var list = Array.isArray(contents) ? contents : [contents];
return list.map(function (item) {
if (typeof item === 'string') {
return {value: item};
}
if (item.language) {
return {value: '\`\`\`' + item.language + '\\n' + item.value + '\\n\`\`\`'};
}
return {value: item.value || ''};
});
}
var COMPLETION_KIND = {
1: 18, 2: 0, 3: 1, 4: 2, 5: 3, 6: 4, 7: 5, 8: 7, 9: 8, 10: 9, 11: 12, 12: 13, 13: 15, 14: 17,
15: 27, 16: 19, 17: 20, 18: 21, 19: 23, 20: 16, 21: 14, 22: 6, 23: 10, 24: 11, 25: 24,
};
function registerProviders(language) {
var client = clients[language];
var capabilities = client.capabilities || {};
var disposables = client.disposables;
if (capabilities.hoverProvider) {
disposables.push(monaco.languages.registerHoverProvider(language, {
provideHover: function (model, position) {
return lspRequest(language, 'textDocument/hover', {
textDocument: textDocument(model), position: toLspPosition(position),
}).then(function (result) {
if (!result || !result.contents) {
return null;
}
return {contents: markdown(result.contents), range: result.range ? toRange(result.range) : undefined};
}).catch(function () { return null; });
},
}));
}
if (capabilities.completionProvider) {
disposables.push(monaco.languages.registerCompletionItemProvider(language, {
triggerCharacters: capabilities.completionProvider.triggerCharacters || [],
provideCompletionItems: function (model, position, context) {
var word = model.getWordUntilPosition(position);
var defaultRange = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
return lspRequest(language, 'textDocument/completion', {
textDocument: textDocument(model),
position: toLspPosition(position),
context: {triggerKind: context.triggerKind === 1 ? 2 : 1, triggerCharacter: context.triggerCharacter},
}).then(function (result) {
var items = Array.isArray(result) ? result : (result && result.items) || [];
return {
incomplete: Boolean(result && result.isIncomplete),
suggestions: items.map(function (item) {
var edit = item.textEdit;
var range = defaultRange;
var insertText = item.insertText || item.label;
if (edit) {
insertText = edit.newText;
range = toRange(edit.range || edit.insert);
}
return {
label: typeof item.label === 'string' ? item.label : item.label.label,
kind: COMPLETION_KIND[item.kind] || 18,
detail: item.detail,
documentation: item.documentation ? markdown(item.documentation)[0] : undefined,
insertText: insertText,
insertTextRules: item.insertTextFormat === 2 ? 4 : 0,
range: range,
sortText: item.sortText,
filterText: item.filterText,
additionalTextEdits: (item.additionalTextEdits || []).map(function (extra) {
return {range: toRange(extra.range), text: extra.newText};
}),
};
}),
};
}).catch(function () { return {suggestions: []}; });
},
}));
}
if (capabilities.definitionProvider) {
disposables.push(monaco.languages.registerDefinitionProvider(language, {
provideDefinition: function (model, position) {
return lspRequest(language, 'textDocument/definition', {
textDocument: textDocument(model), position: toLspPosition(position),
}).then(function (result) {
if (!result) {
return [];
}
return (Array.isArray(result) ? result : [result]).map(toLocation);
}).catch(function () { return []; });
},
}));
}
if (capabilities.referencesProvider) {
disposables.push(monaco.languages.registerReferenceProvider(language, {
provideReferences: function (model, position) {
return lspRequest(language, 'textDocument/references', {
textDocument: textDocument(model), position: toLspPosition(position), context: {includeDeclaration: true},
}).then(function (result) {
return (result || []).map(toLocation);
}).catch(function () { return []; });
},
}));
}
if (capabilities.documentSymbolProvider) {
disposables.push(monaco.languages.registerDocumentSymbolProvider(language, {
provideDocumentSymbols: function (model) {
return lspRequest(language, 'textDocument/documentSymbol', {
textDocument: textDocument(model),
}).then(function (result) {
function convert(symbol) {
var range = symbol.location ? symbol.location.range : symbol.range;
var selection = symbol.selectionRange || range;
return {
name: symbol.name,
detail: symbol.detail || '',
kind: Math.max(0, (symbol.kind || 1) - 1),
tags: [],
range: toRange(range),
selectionRange: toRange(selection),
children: (symbol.children || []).map(convert),
};
}
return (result || []).map(convert);
}).catch(function () { return []; });
},
}));
}
if (capabilities.documentFormattingProvider) {
disposables.push(monaco.languages.registerDocumentFormattingEditProvider(language, {
provideDocumentFormattingEdits: function (model, options) {
return lspRequest(language, 'textDocument/formatting', {
textDocument: textDocument(model),
options: {tabSize: options.tabSize, insertSpaces: options.insertSpaces},
}).then(function (result) {
return (result || []).map(function (edit) {
return {range: toRange(edit.range), text: edit.newText};
});
}).catch(function () { return []; });
},
}));
}
post({type: 'ready'});
if (capabilities.renameProvider) {
disposables.push(monaco.languages.registerRenameProvider(language, {
provideRenameEdits: function (model, position, newName) {
return lspRequest(language, 'textDocument/rename', {
textDocument: textDocument(model), position: toLspPosition(position), newName: newName,
}).then(function (result) {
var edits = [];
var changes = (result && result.changes) || {};
Object.keys(changes).forEach(function (uri) {
changes[uri].forEach(function (edit) {
edits.push({resource: monaco.Uri.parse(uri), versionId: undefined, textEdit: {range: toRange(edit.range), text: edit.newText}});
});
});
((result && result.documentChanges) || []).forEach(function (change) {
if (!change.textDocument) {
return;
}
change.edits.forEach(function (edit) {
edits.push({resource: monaco.Uri.parse(change.textDocument.uri), versionId: undefined, textEdit: {range: toRange(edit.range), text: edit.newText}});
});
});
return {edits: edits};
}).catch(function (error) {
return {edits: [], rejectReason: error && error.message};
});
},
}));
}
if (capabilities.signatureHelpProvider) {
disposables.push(monaco.languages.registerSignatureHelpProvider(language, {
signatureHelpTriggerCharacters: capabilities.signatureHelpProvider.triggerCharacters || ['(', ','],
provideSignatureHelp: function (model, position) {
return lspRequest(language, 'textDocument/signatureHelp', {
textDocument: textDocument(model), position: toLspPosition(position),
}).then(function (result) {
if (!result) {
return null;
}
return {
value: {
signatures: (result.signatures || []).map(function (signature) {
return {
label: signature.label,
documentation: signature.documentation ? markdown(signature.documentation)[0] : undefined,
parameters: (signature.parameters || []).map(function (parameter) {
return {label: parameter.label, documentation: parameter.documentation ? markdown(parameter.documentation)[0] : undefined};
}),
};
}),
activeSignature: result.activeSignature || 0,
activeParameter: result.activeParameter || 0,
},
dispose: function () {},
};
}).catch(function () { return null; });
},
}));
}
}
// ---- Boot --------------------------------------------------------------
function loadScript(src, onload, onerror) {
var script = document.createElement('script');
script.src = src;
script.onload = onload;
script.onerror = onerror;
document.head.appendChild(script);
}
function boot() {
require.config({paths: {vs: monacoBase + '/vs'}});
require(['vs/editor/editor.main'], function () {
// Registered before Shiki, which pairs its grammars against whichever
// modes exist at the moment it runs.
${JSON.stringify(EXTRA_LANGUAGES)}.forEach(function (language) {
monaco.languages.register(language);
});
// No model of its own: every buffer this editor shows is one setFile
// created against a URI, and an implicit one would only be orphaned
// by the first switch.
editor = monaco.editor.create(document.getElementById('container'), Object.assign(
${JSON.stringify(EDITOR_OPTIONS)},
pendingOptions || {},
{model: null}
));
applyTheme();
// A definition that lands in another file: Monaco asks its editor
// service to open it, and that is the native side's job.
monaco.editor.registerEditorOpener({
openCodeEditor: function (source, resource, selectionOrPosition) {
if (resource.scheme !== 'file') {
return false;
}
var line = 1;
var column = 1;
if (selectionOrPosition) {
line = selectionOrPosition.startLineNumber || selectionOrPosition.lineNumber || 1;
column = selectionOrPosition.startColumn || selectionOrPosition.column || 1;
}
post({type: 'openFile', path: relativePath(resource), line: line, column: column});
return true;
},
});
// Whatever the native side asked for while the loader was still
// running, or the file the document was built around if it asked
// for nothing.
window.setFile(pendingFile || ${JSON.stringify({path: file.path, value: file.value, language: file.language})});
pendingFile = null;
// Bound after the first file, so opening one is not itself a change.
// The listener is the editor's rather than the model's, so it
// follows whichever model is attached.
editor.onDidChangeModelContent(function () {
post({type: 'change', path: filePath(), value: editor.getValue()});
var model = editor.getModel();
if (model && model.uri.scheme === 'file') {
lspDidChange(model);
}
});
editor.onDidFocusEditorText(function () {
post({type: 'focus'});
});
var cursorTimer = null;
editor.onDidChangeCursorPosition(function (event) {
clearTimeout(cursorTimer);
cursorTimer = setTimeout(function () {
var model = editor.getModel();
post({
type: 'cursor',
line: event.position.lineNumber,
column: event.position.column,
path: filePath(),
language: model ? model.getLanguageId() : null,
});
}, 50);
});
// Clicking away — into the file tree, or out of the window — is a
// save. The native side drops the write if nothing has changed, so
// this is free when the editor is only being tabbed through.
editor.onDidBlurEditorText(function () {
post({type: 'save', path: filePath(), value: editor.getValue(), blur: true});
var model = editor.getModel();
if (model && model.uri.scheme === 'file') {
lspDidSave(model);
}
});
monaco.editor.onDidChangeMarkers(function (uris) {
uris.forEach(function (uri) {
postDiagnosticsFor(monaco.editor.getModel(uri));
});
});
monaco.editor.onWillDisposeModel(function (model) {
var language = model.getLanguageId();
var client = clients[language];
if (client && client.state === 'ready' && client.versions[model.uri.toString()] != null) {
delete client.versions[model.uri.toString()];
lspNotify(language, 'textDocument/didClose', {textDocument: {uri: model.uri.toString()}});
}
});
post({type: 'ready'});
startShiki();
startShiki();
});
}
loadScript(monacoBase + '/vs/loader.js', boot, function () {
if (monacoBase === CDN) {
post({type: 'loadError', source: monacoBase});
return;
}
monacoBase = CDN;
loadScript(CDN + '/vs/loader.js', boot, function () {
post({type: 'loadError', source: CDN});
});
});
});
</script>
</script>
</body>
</body>
const createStyles = colors => StyleSheet.create({
const createStyles = colors => StyleSheet.create({
editor: {
editor: {
flex: 2,
flex: 1,
backgroundColor: colors.surface,
backgroundColor: 'transparent',
borderRadius: 16,
borderRadius: 16,
},
},
});
});
import {StyleSheet, Text, View} from 'react-native';
import {COMMAND_LABELS, buildKeymap, commandForEvent, keyDownEventsFor} from './keymap';
import {FileSystemError, ServerUnavailableError, fileSystem} from '../fileSystem';
import {NativeModules, Pressable, StyleSheet, Text, View} from 'react-native';
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import {ToastProvider, useToast} from './Toast';
import {activeFile, editorReducer, activeGroup as groupOf, initialEditorState, openPaths, serializeEditorState} from './editorState';
import {glass, useThemedStyles} from '../theme';
import {glass, useThemedStyles} from '../theme';
import {useCallback, useEffect, useRef, useState} from 'react';
import {gutterChanges, lineDiff} from './lineDiff';
import {CodeEditor} from '../CodeEditor';
import {CommandPalette} from './CommandPalette';
import {EditorGroup} from './EditorGroup';
import {FileTree} from './FileTree';
import {FileTree} from './FileTree';
import {SearchSidebar} from './SearchSidebar';
import {SearchSidebar} from './SearchSidebar';
import {Terminal} from './Terminal';
import {TerminalPanel} from './TerminalPanel';
import {fileSystem} from '../fileSystem';
import {Welcome} from './Welcome';
import {resolveVendorSources} from './vendor';
import {useAppContext} from '../../context/AppContext';
import {useFileTree} from './useFileTree';
import {useProjectEvents} from './useProjectEvents';
const MAX_RECENT_PROJECTS = 8;
const MAX_RECENT_FILES = 30;
const SIDEBAR_WIDTH = 260;
const TERMINAL_HEIGHT = 260;
const AUTO_SAVE_DELAY = 800;
const AUTOSAVE_DELAY = 800;
// Listing a key here does not decide whether JS sees it — every key on the
// first responder is emitted and bubbles as `onKeyDown` regardless. What this
// does is claim the key so macOS stops handling it itself, which is what keeps
// Cmd+P off the print dialog and Escape from beeping.
const KEY_DOWN_EVENTS = [
{key: 'p', metaKey: true},
{key: 'Escape'},
];
export function CodePage() {
return (
<ToastProvider>
<CodePageInner />
</ToastProvider>
);
}
export function CodePage({activeSidebarItem}) {
/**
* The Code page: a project's files, the editors open on them, a shell, and
* the panels around them. This component owns the state the panels share and
* the rules that tie them together — a file that changes on disk reloads in
* its tab, a save refreshes the git gutter, a shortcut pressed in a WebView
* lands here like any other.
*/
function CodePageInner() {
const styles = useThemedStyles(createStyles);
const styles = useThemedStyles(createStyles);
const [source, setSource] = useState('// some comment\n');
const [currentFile, setCurrentFile] = useState(null);
const [openedFiles, setOpenedFiles] = useState([]);
const toast = useToast();
const {get, set} = useAppContext();
const sidebarItem = get('app.activeSidebarItem');
const [sources, setSources] = useState(null);
// ---- Persisted state -----------------------------------------------------
const [loadedState, setLoadedState] = useState(null);
const [recentProjects, setRecentProjects] = useState([]);
const [projectRoot, setProjectRoot] = useState(null);
const [projectRoot, setProjectRoot] = useState(null);
const projectsState = useRef({});
// ---- Editor state ---------------------------------------------------------
const [editor, dispatch] = useReducer(editorReducer, undefined, initialEditorState);
const [buffers, setBuffers] = useState({});
const buffersRef = useRef(buffers);
buffersRef.current = buffers;
const [closedTabs, setClosedTabs] = useState([]);
const [recentFiles, setRecentFiles] = useState([]);
const editorRefs = useRef({});
const saveTimers = useRef({});
const pendingReveal = useRef(null);
const [cursor, setCursor] = useState(null);
const [searching, setSearching] = useState(false);
const [saving, setSaving] = useState(false);
// ---- Panels ---------------------------------------------------------------
const [panel, setPanel] = useState('files');
const [showSidebar, setShowSidebar] = useState(true);
const [showTerminal, setShowTerminal] = useState(true);
const [palette, setPalette] = useState(null);
const [searchSeed] = useState('');
const [monacoActions, setMonacoActions] = useState([]);
// ---- Git, diagnostics, terminals -----------------------------------------
const [git, setGit] = useState(null);
const [originals, setOriginals] = useState({});
const [diff, setDiff] = useState(null);
const [diagnostics, setDiagnostics] = useState({});
const [terminals, setTerminals] = useState([]);
const [activeTerminal, setActiveTerminal] = useState(null);
const terminalCounter = useRef(0);
const page = useRef(null);
const page = useRef(null);
const save = useCallback(
const report = useCallback(
async (contents = source) => {
if (!currentFile) {
return;
(error, context) => {
const message = error instanceof FileSystemError ? error.message : error?.message ?? String(error);
toast.show(context ? `${context}: ${message}` : message, {kind: 'error'});
},
[toast],
);
const tree = useFileTree(projectRoot, {onError: report});
// ---- Boot: vendor sources, saved state ------------------------------------
useEffect(() => {
resolveVendorSources().then(setSources);
}, []);
useEffect(() => {
fileSystem
.getState()
.then(state => {
setLoadedState(state ?? {});
setRecentProjects(state?.recentProjects ?? []);
projectsState.current = state?.projects ?? {};
if (state?.lastProject) {
fileSystem
.stat(state.lastProject, '')
.then(() => openProject(state.lastProject, {restore: true}))
.catch(() => {});
}
})
.catch(error => {
setLoadedState({});
if (!(error instanceof ServerUnavailableError)) {
report(error, 'Could not load saved state');
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Everything worth remembering, written a moment after it changes.
useEffect(() => {
if (!loadedState) {
return undefined;
}
const timer = setTimeout(() => {
if (projectRoot) {
projectsState.current[projectRoot] = {
editor: serializeEditorState(editor),
recentFiles,
panel,
terminals: terminals.length,
};
}
fileSystem
.patchState({
recentProjects,
lastProject: projectRoot,
projects: projectsState.current,
})
.catch(() => {});
}, 600);
return () => clearTimeout(timer);
}, [loadedState, recentProjects, projectRoot, editor, recentFiles, panel, terminals.length]);
// ---- Keyboard -------------------------------------------------------------
const keymap = useMemo(() => buildKeymap(), []);
const appKeys = useMemo(() => keyDownEventsFor(keymap), [keymap]);
const activeEditor = useCallback(() => editorRefs.current[editor.activeGroup], [editor.activeGroup]);
// ---- Buffers --------------------------------------------------------------
const updateBuffer = useCallback((path, update) => {
setBuffers(current => {
const existing = current[path];
const next = typeof update === 'function' ? update(existing) : {...existing, ...update};
if (!next) {
const {[path]: _, ...rest} = current;
return rest;
}
}
return {...current, [path]: next};
});
}, []);
await fileSystem.writeFile(projectRoot, currentFile, contents);
const loadOriginal = useCallback(
async (root, path) => {
try {
const response = await fileSystem.git.show(root, path);
setOriginals(current => ({...current, [path]: response.contents ?? ''}));
} catch (error) {
// Not a repository, or the server is down; the gutter stays empty.
}
},
},
[currentFile, source, projectRoot],
[],
);
);
const openFile = useCallback(
const loadBuffer = useCallback(
async path => {
// The outgoing file first — the debounce below may still be pending, and
// it is cancelled the moment `currentFile` changes.
await save();
async (root, path) => {
const response = await fileSystem.readFile(projectRoot, path);
const response = await fileSystem.readFile(root, path);
const contents = response.contents ?? '';
const contents = response.contents ?? '';
updateBuffer(path, {contents, disk: contents, mtime: response.mtime, dirty: false, conflict: false});
return contents;
},
[updateBuffer],
);
let currentOpenedFiles = [...openedFiles];
if (!currentOpenedFiles.includes(path)) {
currentOpenedFiles.push(path);
setOpenedFiles(currentOpenedFiles);
const openFile = useCallback(
async (path, {line, column, groupId, background = false} = {}) => {
if (!projectRoot || !path) {
return;
}
}
setCurrentFile(path);
try {
if (!buffersRef.current[path]) {
await loadBuffer(projectRoot, path);
if (git?.repository) {
loadOriginal(projectRoot, path);
}
}
} catch (error) {
report(error, `Could not open ${path}`);
return;
}
setSource(contents);
setCurrentFile(path);
dispatch({type: 'open', path, groupId, background});
setRecentFiles(current => [path, ...current.filter(entry => entry !== path)].slice(0, MAX_RECENT_FILES));
setDiff(null);
if (line) {
pendingReveal.current = {path, line, column: column ?? 1, groupId: groupId ?? editor.activeGroup};
}
},
},
[save, projectRoot, openedFiles, setOpenedFiles],
[projectRoot, loadBuffer, loadOriginal, git, report, editor.activeGroup],
);
);
// 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
// one path can never land on the next one.
// The editor needs the file switched before it can jump; the pending jump
// is applied once the group shows the file.
useEffect(() => {
useEffect(() => {
if (!currentFile) {
return;
const pending = pendingReveal.current;
if (!pending) {
return undefined;
}
}
const timer = setTimeout(() => save(source), AUTO_SAVE_DELAY);
const group = editor.groups.find(candidate => candidate.id === pending.groupId) ?? groupOf(editor);
if (group?.active !== pending.path) {
return undefined;
}
const timer = setTimeout(() => {
editorRefs.current[group.id]?.revealPosition(pending.line, pending.column);
pendingReveal.current = null;
}, 120);
return () => clearTimeout(timer);
return () => clearTimeout(timer);
}, [currentFile, source, save]);
}, [editor, buffers]);
// 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
// binding when it is in the editor's WebView, and through the document on
// the web build. All three land here.
const onKeyDown = useCallback(event => {
const {key, metaKey, ctrlKey} = event.nativeEvent ?? event;
const writeBuffer = useCallback(
async (path, contents, {force = false} = {}) => {
const buffer = buffersRef.current[path];
if (key === 'p' && (metaKey || ctrlKey)) {
event.preventDefault?.();
if (!projectRoot || !buffer) {
return;
}
if (!force && contents === buffer.disk) {
return;
}
clearTimeout(saveTimers.current[path]);
setSearching(true);
setSaving(true);
try {
const response = await fileSystem.writeFile(projectRoot, path, contents, force ? undefined : buffer.mtime);
updateBuffer(path, current => ({...current, contents: current?.contents ?? contents, disk: contents, mtime: response.mtime, dirty: (current?.contents ?? contents) !== contents, conflict: false}));
if (git?.repository) {
refreshGit();
}
} catch (error) {
if (error instanceof FileSystemError && error.status === 409) {
updateBuffer(path, {conflict: true});
toast.show(`${path} changed on disk. Reload it, or keep yours and overwrite.`, {
kind: 'error',
sticky: true,
action: {label: 'Overwrite', onPress: () => writeBuffer(path, buffersRef.current[path]?.contents ?? contents, {force: true})},
});
} else {
report(error, `Could not save ${path}`);
}
} finally {
setSaving(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectRoot, updateBuffer, git, report, toast],
);
const scheduleAutosave = useCallback(
path => {
clearTimeout(saveTimers.current[path]);
saveTimers.current[path] = setTimeout(() => {
const buffer = buffersRef.current[path];
if (buffer && buffer.dirty && !buffer.conflict) {
writeBuffer(path, buffer.contents);
}
}, AUTOSAVE_DELAY);
},
[writeBuffer],
);
const onEditorChange = useCallback(
(value, path) => {
if (!path) {
return;
}
updateBuffer(path, current => (current ? {...current, contents: value, dirty: value !== current.disk} : current));
scheduleAutosave(path);
},
[updateBuffer, scheduleAutosave],
);
const onEditorSave = useCallback(
(value, path, blur) => {
if (!path) {
return;
}
updateBuffer(path, current => (current ? {...current, contents: value, dirty: value !== current.disk} : current));
const buffer = buffersRef.current[path];
if (buffer?.conflict) {
return;
}
writeBuffer(path, value);
},
[updateBuffer, writeBuffer],
);
const saveActive = useCallback(() => {
const path = activeFile(editor);
if (!path) {
return;
}
}
if (key === 'Escape') {
setSearching(false);
const ref = activeEditor();
if (ref) {
ref.requestSave(false);
} else {
writeBuffer(path, buffersRef.current[path]?.contents ?? '');
}
}
}, [editor, activeEditor, writeBuffer]);
const saveAll = useCallback(() => {
for (const [path, buffer] of Object.entries(buffersRef.current)) {
if (buffer.dirty && !buffer.conflict) {
writeBuffer(path, buffer.contents);
}
}
}, [writeBuffer]);
const resolveConflict = useCallback(
async (path, choice) => {
if (choice === 'reload') {
try {
await loadBuffer(projectRoot, path);
} catch (error) {
report(error, `Could not reload ${path}`);
}
} else {
await writeBuffer(path, buffersRef.current[path]?.contents ?? '', {force: true});
}
},
[projectRoot, loadBuffer, writeBuffer, report],
);
// ---- Project --------------------------------------------------------------
const closeProject = useCallback(() => {
for (const timer of Object.values(saveTimers.current)) {
clearTimeout(timer);
}
saveTimers.current = {};
setBuffers({});
setOriginals({});
setDiagnostics({});
setDiff(null);
setGit(null);
setClosedTabs([]);
setRecentFiles([]);
setTerminals([]);
setActiveTerminal(null);
dispatch({type: 'restore', groups: []});
}, []);
}, []);
// Nothing in this tree takes focus on its own, and on macOS key events are
// only emitted from the first responder — a plain View never becomes one, and
// clicks deliberately don't move focus. Without this the shortcut only works
// while Monaco holds focus, because Monaco is the only thing here that does.
const openProject = useCallback(
async (root, {restore = true} = {}) => {
if (!root) {
return;
}
closeProject();
setProjectRoot(root);
setRecentProjects(current => [root, ...current.filter(entry => entry !== root)].slice(0, MAX_RECENT_PROJECTS));
const saved = projectsState.current[root];
const wanted = Math.max(1, Math.min(4, saved?.terminals ?? 1));
const list = Array.from({length: wanted}, () => ({id: ++terminalCounter.current}));
setTerminals(list);
setActiveTerminal(list[0].id);
if (restore && saved?.editor?.groups?.length) {
const paths = [...new Set(saved.editor.groups.flatMap(group => group.tabs ?? []))];
const loaded = await Promise.all(
paths.map(path =>
fileSystem
.readFile(root, path)
.then(response => [path, response])
.catch(() => null),
),
);
const okPaths = new Set();
setBuffers(() => {
const next = {};
for (const entry of loaded) {
if (!entry) {
continue;
}
const [path, response] = entry;
const contents = response.contents ?? '';
next[path] = {contents, disk: contents, mtime: response.mtime, dirty: false, conflict: false};
okPaths.add(path);
}
return next;
});
dispatch({
type: 'restore',
groups: saved.editor.groups.map(group => ({
tabs: (group.tabs ?? []).filter(path => okPaths.has(path)),
active: okPaths.has(group.active) ? group.active : null,
})),
activeIndex: saved.editor.activeIndex ?? 0,
});
setRecentFiles(saved.recentFiles ?? []);
}
},
[closeProject],
);
const pickFolder = useCallback(async () => {
const picker = NativeModules.FolderPicker;
if (!picker?.pick) {
toast.show('Opening a folder is only available on macOS.', {kind: 'info'});
return;
}
try {
const path = await picker.pick();
if (path) {
await openProject(path, {restore: true});
}
} catch (error) {
report(error, 'Could not open folder');
}
}, [openProject, report, toast]);
// ---- Git --------------------------------------------------------------------
const gitTimer = useRef(null);
const refreshGit = useCallback(() => {
if (!projectRoot) {
return Promise.resolve();
}
clearTimeout(gitTimer.current);
return new Promise(resolve => {
gitTimer.current = setTimeout(async () => {
const status = await fileSystem.git.status(projectRoot);
setGit(status);
if (status.repository) {
for (const path of openPaths(editor)) {
loadOriginal(projectRoot, path);
}
}
}, 150);
});
}, [projectRoot, editor, loadOriginal]);
useEffect(() => {
useEffect(() => {
page.current?.focus?.();
}, []);
if (projectRoot) {
refreshGit();
}
// Only when the project changes; saves and events refresh on their own.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectRoot]);
const gitByPath = useMemo(() => {
const map = {};
for (const change of git?.changes ?? []) {
map[change.path] = change;
}
return map;
}, [git]);
const showDiff = useCallback(
async (path, {staged = false} = {}) => {
await openFile(path);
try {
const response = await fileSystem.git.show(projectRoot, path);
setDiff({path, staged, original: response.contents ?? ''});
} catch (error) {
report(error, 'Could not load the committed version');
}
},
[openFile, projectRoot, report],
);
// ---- File changes from outside ---------------------------------------------
const onProjectChanges = useCallback(
changes => {
tree.applyChanges(changes.filter(change => change.kind !== 'git'));
// Closing hands focus back, so the next Cmd+P outside the editor still lands.
let gitTouched = changes.some(change => change.kind === 'git');
for (const change of changes) {
if (change.kind === 'git') {
continue;
}
gitTouched = true;
const buffer = buffersRef.current[change.path];
if (!buffer) {
continue;
}
if (change.kind === 'delete') {
if (buffer.dirty) {
updateBuffer(change.path, {conflict: true, mtime: null});
toast.show(`${change.path} was deleted on disk. Saving will recreate it.`, {kind: 'info'});
} else {
dispatch({type: 'closeEverywhere', path: change.path});
updateBuffer(change.path, () => null);
toast.show(`${change.path} was deleted on disk.`, {kind: 'info'});
}
continue;
}
fileSystem
.readFile(projectRoot, change.path)
.then(response => {
const contents = response.contents ?? '';
const current = buffersRef.current[change.path];
if (!current || contents === current.disk) {
if (current) {
updateBuffer(change.path, {mtime: response.mtime});
}
return;
}
if (!current.dirty) {
updateBuffer(change.path, {contents, disk: contents, mtime: response.mtime, dirty: false, conflict: false});
} else {
updateBuffer(change.path, {disk: contents, mtime: response.mtime, conflict: true, dirty: true});
}
})
.catch(() => {});
}
if (gitTouched && projectRoot) {
refreshGit();
}
},
[tree, updateBuffer, toast, projectRoot, refreshGit],
);
useProjectEvents(projectRoot, onProjectChanges, {enabled: true});
// ---- Tree actions ------------------------------------------------------------
const createFile = useCallback(
async path => {
try {
await fileSystem.createFile(projectRoot, path, '');
await openFile(path);
} catch (error) {
report(error, `Could not create ${path}`);
}
},
[projectRoot, openFile, report],
);
const createFolder = useCallback(
async path => {
try {
await fileSystem.createDirectory(projectRoot, path);
tree.expand(path);
} catch (error) {
report(error, `Could not create ${path}`);
}
},
[projectRoot, tree, report],
);
const renamePath = useCallback(
async (from, to, isDirectory) => {
try {
// A pending write must land under the old name, or not at all.
const buffer = buffersRef.current[from];
if (buffer?.dirty) {
await writeBuffer(from, buffer.contents);
}
await fileSystem.rename(projectRoot, from, to);
if (isDirectory) {
dispatch({type: 'renameFolder', from, to});
setBuffers(current => {
const next = {};
for (const [path, value] of Object.entries(current)) {
const moved = path === from || path.startsWith(`${from}/`) ? to + path.slice(from.length) : path;
next[moved] = value;
}
return next;
});
} else {
dispatch({type: 'rename', from, to});
setBuffers(current => {
if (!current[from]) {
return current;
}
const {[from]: moved, ...rest} = current;
return {...rest, [to]: moved};
});
}
} catch (error) {
report(error, `Could not rename ${from}`);
}
},
[projectRoot, writeBuffer, report],
);
const deletePath = useCallback(
async (path, isDirectory) => {
try {
await fileSystem.deletePath(projectRoot, path);
const affected = Object.keys(buffersRef.current).filter(entry => entry === path || (isDirectory && entry.startsWith(`${path}/`)));
for (const entry of affected) {
clearTimeout(saveTimers.current[entry]);
dispatch({type: 'closeEverywhere', path: entry});
updateBuffer(entry, () => null);
}
toast.show(`Deleted ${path}`, {kind: 'success'});
} catch (error) {
report(error, `Could not delete ${path}`);
}
},
[projectRoot, updateBuffer, toast, report],
);
// ---- Tabs -----------------------------------------------------------------------
const closeTab = useCallback(
(path, groupId) => {
const buffer = buffersRef.current[path];
if (buffer?.dirty && !buffer.conflict) {
writeBuffer(path, buffer.contents);
}
dispatch({type: 'close', path, groupId});
setClosedTabs(current => [path, ...current.filter(entry => entry !== path)].slice(0, 20));
if (diff?.path === path) {
setDiff(null);
}
},
[writeBuffer, diff],
);
// A buffer nobody shows any more is dropped, so a file reopened later is
// read fresh. Dirty ones stay until they are saved.
useEffect(() => {
useEffect(() => {
if (!searching) {
page.current?.focus?.();
const open = new Set(openPaths(editor));
setBuffers(current => {
let changed = false;
const next = {};
for (const [path, buffer] of Object.entries(current)) {
if (open.has(path) || buffer.dirty) {
next[path] = buffer;
} else {
changed = true;
}
}
return changed ? next : current;
});
setDiagnostics(current => {
const next = {};
let changed = false;
for (const [path, list] of Object.entries(current)) {
if (open.has(path)) {
next[path] = list;
} else {
changed = true;
}
}
return changed ? next : current;
});
}, [editor]);
// ---- Commands --------------------------------------------------------------------
const showPanel = useCallback(
id => {
setPanel(id);
setShowSidebar(true);
if (id === 'files' || id === 'search') {
set('app.activeSidebarItem', id);
}
},
[set],
);
useEffect(() => {
if ((sidebarItem === 'files' || sidebarItem === 'search') && sidebarItem !== panel) {
setPanel(sidebarItem);
}
}
}, [searching]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sidebarItem]);
const addTerminal = useCallback(() => {
const id = ++terminalCounter.current;
setTerminals(current => [...current, {id}]);
setActiveTerminal(id);
setShowTerminal(true);
}, []);
const closeTerminal = useCallback(
id => {
setTerminals(current => {
const next = current.filter(terminal => terminal.id !== id);
setActiveTerminal(active => (active === id ? next[next.length - 1]?.id ?? null : active));
return next;
});
},
[],
);
const commands = useMemo(() => {
const binding = id => keymap.find(entry => entry.command === id)?.text;
const define = (id, run, {label = COMMAND_LABELS[id] ?? id, category = null} = {}) => ({id, label, category, keybinding: binding(id), run});
const list = [
define('quickOpen', () => setPalette({mode: 'files'}), {category: 'Go'}),
define('commandPalette', () => setPalette({mode: 'commands'}), {category: 'View'}),
define('goToSymbol', () => activeEditor()?.runAction('editor.action.quickOutline'), {category: 'Go'}),
define('goToLine', () => setPalette({mode: 'line'}), {category: 'Go'}),
define('save', saveActive, {category: 'File'}),
define('saveAll', saveAll, {category: 'File'}),
define('closeTab', () => {
const path = activeFile(editor);
if (path) {
closeTab(path, editor.activeGroup);
}
}, {category: 'View'}),
define('closeAllTabs', () => dispatch({type: 'closeAll'}), {category: 'View'}),
define('reopenClosedTab', () => {
const [path, ...rest] = closedTabs;
if (path) {
setClosedTabs(rest);
openFile(path);
}
}, {category: 'View'}),
define('nextTab', () => dispatch({type: 'next'}), {category: 'View'}),
define('previousTab', () => dispatch({type: 'previous'}), {category: 'View'}),
define('nextTabAlt', () => dispatch({type: 'next'}), {category: 'View'}),
define('previousTabAlt', () => dispatch({type: 'previous'}), {category: 'View'}),
define('splitEditor', () => dispatch({type: editor.groups.length > 1 ? 'unsplit' : 'split'}), {
category: 'View',
label: editor.groups.length > 1 ? 'Join editors' : 'Split editor',
}),
define('focusFirstGroup', () => editor.groups[0] && dispatch({type: 'activateGroup', groupId: editor.groups[0].id}), {category: 'View'}),
define('focusSecondGroup', () => editor.groups[1] && dispatch({type: 'activateGroup', groupId: editor.groups[1].id}), {category: 'View'}),
define('toggleSidebar', () => setShowSidebar(current => !current), {category: 'View'}),
define('toggleTerminal', () => {
if (!showTerminal && terminals.length === 0) {
addTerminal();
}
setShowTerminal(current => !current);
}, {category: 'View'}),
define('newTerminal', addTerminal, {category: 'Terminal'}),
define('showFiles', () => showPanel('files'), {category: 'View'}),
define('showSearch', () => showPanel('search'), {category: 'View'}),
define('showGit', () => showPanel('git'), {category: 'View'}),
define('showProblems', () => showPanel('problems'), {category: 'View'}),
define('openFolder', pickFolder, {category: 'File'}),
define('newFile', () => {
showPanel('files');
toast.show('Use the + in the file tree to name the new file.', {kind: 'info'});
}, {category: 'File'}),
define('formatDocument', () => activeEditor()?.runAction('editor.action.formatDocument'), {category: 'Editor'}),
define('dismiss', () => {
setPalette(null);
setDiff(null);
page.current?.focus?.();
}, {category: 'View'}),
{id: 'closeProject', label: 'Close folder', category: 'File', run: () => { closeProject(); setProjectRoot(null); }},
{id: 'refreshGit', label: 'Refresh git status', category: 'Git', run: refreshGit},
{id: 'stageAll', label: 'Stage all changes', category: 'Git', run: () => fileSystem.git.stage(projectRoot, 'all').then(refreshGit).catch(report)},
{id: 'revealInTree', label: 'Reveal active file in tree', category: 'View', run: () => { const path = activeFile(editor); if (path) { showPanel('files'); tree.reveal(path); } }},
{id: 'showDiff', label: 'Compare active file with HEAD', category: 'Git', run: () => { const path = activeFile(editor); if (path) { showDiff(path); } }},
{id: 'reloadEditor', label: 'Reload editor view', category: 'Developer', run: () => activeEditor()?.reload()},
];
for (const action of monacoActions) {
list.push({id: `monaco:${action.id}`, label: action.label, category: 'Editor', run: () => activeEditor()?.runAction(action.id)});
}
return list;
}, [keymap, editor, closedTabs, terminals.length, showTerminal, monacoActions, projectRoot, activeEditor, saveActive, saveAll, closeTab, openFile, addTerminal, showPanel, pickFolder, closeProject, refreshGit, report, toast, tree, showDiff]);
const runCommand = useCallback(
id => {
const command = commands.find(entry => entry.id === id);
command?.run?.();
},
[commands],
);
// A key from the page, from Monaco, from xterm or from the document: one
// handler. A palette that is open takes Escape itself; the rest is the keymap.
const onKeyDown = useCallback(
event => {
const command = commandForEvent(keymap, event);
if (!command) {
return;
}
event.preventDefault?.();
if (command === 'dismiss' && !palette && !diff) {
return;
}
runCommand(command);
},
[keymap, palette, diff, runCommand],
);
useEffect(() => {
useEffect(() => {
if (typeof document === 'undefined') {
if (typeof document === 'undefined') {
return;
return undefined;
}
}
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
}, [onKeyDown]);
const openSearchFile = useCallback(
async path => {
setSearching(false);
await openFile(path);
},
[openFile],
);
// Nothing in this tree takes focus on its own, and on macOS key events are
// only emitted from the first responder — a plain View never becomes one, and
// clicks deliberately don't move focus. Without this the shortcut only works
// while Monaco holds focus, because Monaco is the only thing here that does.
useEffect(() => {
if (!palette) {
page.current?.focus?.();
}
}, [palette]);
return (
<View ref={page} focusable enableFocusRing={false} style={styles.editor} onKeyDown={onKeyDown} keyDownEvents={KEY_DOWN_EVENTS}>
{activeSidebarItem == "files" && (
<View style={styles.fileTree}>
<FileTree currentFile={currentFile} onOpenFile={openFile} onSave={save} projectRoot={projectRoot} setProjectRoot={setProjectRoot} openedFiles={openedFiles} setOpenedFiles={setOpenedFiles} />
</View>
)}
// Monaco's actions join the palette the first time it opens on commands.
useEffect(() => {
if (palette?.mode === 'commands' && monacoActions.length === 0) {
activeEditor()?.listActions?.().then(actions => setMonacoActions(actions ?? []));
}
}, [palette, monacoActions.length, activeEditor]);
{activeSidebarItem == "search" && (
<View style={styles.fileTree}>
<SearchSidebar onOpenFile={openFile} projectRoot={projectRoot} />
</View>
)}
// ---- Derived --------------------------------------------------------------------
<View style={styles.codeEditorContainer}>
{openedFiles.length > 0 && (
<View style={styles.openedFiles}>
{openedFiles.map(file => (
<View key={file} style={[styles.openedFile, file === currentFile && styles.openedFileActive]}>
<Text onPress={() => openFile(file)} style={[styles.openedFileText, file === currentFile && styles.openedFileActiveText]}>{file.split('/').pop()}</Text>
</View>
))}
</View>
)}
const dirtyPaths = useMemo(() => new Set(Object.entries(buffers).filter(([, buffer]) => buffer.dirty).map(([path]) => path)), [buffers]);
const problemsByPath = useMemo(() => {
const map = {};
for (const [path, list] of Object.entries(diagnostics)) {
map[path] = {
errors: list.filter(problem => problem.severity === 'error').length,
warnings: list.filter(problem => problem.severity === 'warning').length,
};
}
return map;
}, [diagnostics]);
const gutterByGroup = useMemo(() => {
const map = {};
for (const group of editor.groups) {
const path = group.active;
if (!path || originals[path] === undefined || !buffers[path]) {
map[group.id] = [];
continue;
}
map[group.id] = gutterChanges(lineDiff(originals[path], buffers[path].contents));
}
return map;
}, [editor.groups, originals, buffers]);
{projectRoot && (
const activePath = activeFile(editor);
return (
<View
ref={page}
focusable
enableFocusRing={false}
style={styles.page}
onKeyDown={onKeyDown}
keyDownEvents={appKeys}
testID="code-page">
<View style={styles.body}>
{showSidebar && (
<>
<>
<CodeEditor
value={source}
path={currentFile}
onChange={setSource}
onSave={save}
onSearch={() => setSearching(true)}
<View style={[styles.sidebar, {width: SIDEBAR_WIDTH}]} testID="sidebar">
{panel === 'files' && (
<FileTree
projectRoot={projectRoot}
tree={tree}
currentFile={activePath}
openFiles={openPaths(editor)}
dirtyPaths={dirtyPaths}
gitByPath={gitByPath}
onOpenFile={path => openFile(path)}
onOpenFolder={pickFolder}
onCreateFile={createFile}
onCreateFolder={createFolder}
onRename={renamePath}
onDelete={deletePath}
onCollapseAll={() => {
for (const path of tree.expanded) {
tree.toggle(path);
}
}}
/>
/>
)}
<View style={styles.terminal}>
<Terminal projectRoot={projectRoot} onSearch={() => setSearching(true)} />
{panel === 'search' && (
<SearchSidebar
projectRoot={projectRoot}
initialTerm={searchSeed}
onOpenFile={(path, line, column) => openFile(path, {line, column})}
onReplaced={outcome => toast.show(`Replaced ${outcome.replacements} occurrence${outcome.replacements === 1 ? '' : 's'} in ${outcome.changed.length} file${outcome.changed.length === 1 ? '' : 's'}.`, {kind: 'success'})}
onError={report}
/>
)}
</View>
</View>
</>
</>
)}
)}
<View style={styles.main}>
{!projectRoot && (
{!projectRoot && (
<View style={styles.noProjectRoot}>
<Text style={styles.noProjectRootText}>No project opend</Text>
<Welcome
recentProjects={recentProjects}
onOpenFolder={pickFolder}
onOpenRecent={path => openProject(path, {restore: true})}
onForgetRecent={path => setRecentProjects(current => current.filter(entry => entry !== path))}
keybindings={{openFolder: commands.find(command => command.id === 'openFolder')?.keybinding}}
/>
)}
{projectRoot && sources && (
<View style={styles.groups} testID="editor-groups">
{editor.groups.map((group, index) => (
<React.Fragment key={group.id}>
{index > 0 && <View style={styles.groupGap} />}
<EditorGroup
group={group}
isActive={group.id === editor.activeGroup}
canSplit={editor.groups.length < 2}
hasOtherGroup={editor.groups.length > 1}
buffers={buffers}
dirtyPaths={dirtyPaths}
gitByPath={gitByPath}
problemsByPath={problemsByPath}
gutterChanges={gutterByGroup[group.id]}
diff={diff && diff.path === group.active ? diff : null}
conflict={Boolean(group.active && buffers[group.active]?.conflict)}
appKeys={appKeys}
sources={sources}
projectRoot={projectRoot}
editorRef={instance => {
editorRefs.current[group.id] = instance;
}}
onActivateGroup={groupId => dispatch({type: 'activateGroup', groupId})}
onActivateTab={(path, groupId) => dispatch({type: 'activate', path, groupId})}
onCloseTab={closeTab}
onCloseOthers={(path, groupId) => dispatch({type: 'closeOthers', path, groupId})}
onCloseAll={groupId => dispatch({type: 'closeAll', groupId})}
onMoveTab={(path, to, groupId) => dispatch({type: 'move', path, to, groupId})}
onSplit={() => dispatch({type: 'split'})}
onMoveToOtherGroup={path => {
const other = editor.groups.find(candidate => candidate.id !== group.id);
if (other) {
dispatch({type: 'moveToGroup', path, groupId: other.id});
}
}}
onCloseGroup={() => dispatch({type: 'unsplit'})}
onChange={onEditorChange}
onSave={onEditorSave}
onCommand={onKeyDown}
onOpenFile={({path, line, column}) => openFile(path, {line, column, groupId: group.id})}
onCursor={position => {
if (group.id === editor.activeGroup) {
setCursor(position);
}
}}
onDiagnostics={({path, diagnostics: list}) => setDiagnostics(current => ({...current, [path]: list}))}
onLoadError={() => toast.show('The editor could not load. Check the network, or start the file server for the offline copy.', {kind: 'error'})}
onCloseDiff={() => setDiff(null)}
onResolveConflict={resolveConflict}
/>
</React.Fragment>
))}
</View>
</View>
)}
)}
{projectRoot && !sources && (
<View style={styles.loading}>
<Text style={styles.loadingText}>Loading editor…</Text>
</View>
)}
{projectRoot && showTerminal && terminals.length > 0 && sources && (
<>
<TerminalPanel
style={{height: TERMINAL_HEIGHT}}
projectRoot={projectRoot}
terminals={terminals}
activeId={activeTerminal}
onActivate={setActiveTerminal}
onAdd={addTerminal}
onClose={closeTerminal}
onCollapse={() => setShowTerminal(false)}
appKeys={appKeys}
sources={sources}
onCommand={onKeyDown}
/>
</>
)}
</View>
</View>
</View>
<CommandPalette
visible={Boolean(palette)}
initialMode={palette?.mode}
projectRoot={projectRoot}
commands={commands}
recentFiles={recentFiles}
onClose={() => setPalette(null)}
onOpenFile={path => openFile(path)}
onGoToLine={line => activeEditor()?.revealPosition(line, 1)}
onGoToSymbol={() => activeEditor()?.runAction('editor.action.quickOutline')}
/>
</View>
</View>
);
);
}
}
const createStyles = colors => StyleSheet.create({
const createStyles = colors => StyleSheet.create({
editor: {
flexDirection: 'row',
position: 'relative',
gap: 16,
page: {
flex: 1,
flex: 1,
position: 'relative',
},
},
codeEditorContainer: {
flex: 5,
paddingTop: 16,
paddingBottom: 16,
borderRadius: 16,
gap: 16,
},
openedFiles: {
serverBanner: {
flexDirection: 'row',
flexDirection: 'row',
gap: 8,
alignItems: 'center',
gap: 12,
marginTop: 16,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 10,
borderColor: '#f85149',
...glass(colors, {variant: 'tinted'}),
},
},
openedFile: {
padding: 8,
...glass(colors, {variant: 'subtle'}),
borderRadius: 90,
opacity: 0.75,
serverBannerText: {
flex: 1,
color: colors.onSurface,
fontSize: 12.5,
},
},
openedFileActive: {
serverRetry: {
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 8,
...glass(colors, {variant: 'accent'}),
...glass(colors, {variant: 'accent'}),
opacity: 1,
},
},
fileTree: {
serverRetryText: {
color: colors.onPrimary,
fontSize: 12,
fontWeight: '600',
},
body: {
flex: 1,
flex: 1,
flexDirection: 'row',
gap: 12,
},
},
terminal: {
borderRadius: 16,
height: 300,
// The emulator inside draws to the edges, so the panel's own rounding has
// to clip it — otherwise the scrollback runs out over the corners.
overflow: 'hidden',
...glass(colors, {variant: 'subtle'})
main: {
flex: 1,
minWidth: 0,
paddingTop: 16,
paddingBottom: 16,
gap: 10,
},
},
openedFileText: {
color: colors.onSurface,
groups: {
flex: 1,
flexDirection: 'row',
minHeight: 0,
},
},
openedFileActiveText: {
color: colors.onPrimary,
groupGap: {
width: 10,
},
},
noProjectRoot: {
loading: {
flex: 1,
flex: 1,
justifyContent: 'center',
alignItems: 'center',
alignItems: 'center',
justifyContent: 'center',
},
},
noProjectRootText: {
fontSize: 32,
loadingText: {
color: colors.onSurfaceVariant,
color: colors.onSurfaceVariant,
textAlign: 'center',
fontWeight: 'bold',
opacity: 0.75,
fontSize: 14,
},
},
});
});
import {Pressable, ScrollView, StyleSheet, Text} from 'react-native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {Field} from './ui';
import {FileIcon} from '../icons/FileIcon';
import {fileSystem} from '../fileSystem';
import {formatKeybinding} from './keymap';
import {fuzzyFilter} from './fuzzy';
/**
* One overlay, several modes, told apart by the first character the way VS
* Code does it: plain text finds files, `>` runs commands, `:` goes to a line,
* `@` goes to a symbol. Arrow keys move, Enter runs, Escape closes.
*/
export function CommandPalette({visible, initialMode = 'files', projectRoot, commands, recentFiles = [], onClose, onOpenFile, onGoToLine, onGoToSymbol}) {
const styles = useThemedStyles(createStyles);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const [files, setFiles] = useState([]);
const inputRef = useRef(null);
const requestId = useRef(0);
useEffect(() => {
if (visible) {
setQuery(initialMode === 'commands' ? '>' : initialMode === 'line' ? ':' : initialMode === 'symbol' ? '@' : '');
setSelected(0);
setTimeout(() => inputRef.current?.focus?.(), 30);
}
}, [visible, initialMode]);
const mode = query.startsWith('>') ? 'commands' : query.startsWith(':') ? 'line' : query.startsWith('@') ? 'symbol' : 'files';
const text = mode === 'files' ? query : query.slice(1);
useEffect(() => {
if (!visible || mode !== 'files' || !projectRoot) {
return undefined;
}
const id = ++requestId.current;
if (!text.trim()) {
setFiles(recentFiles.map(path => ({path})));
return undefined;
}
const timer = setTimeout(() => {
fileSystem
.searchFiles(projectRoot, text.trim(), 'files')
.then(response => {
if (id === requestId.current) {
setFiles(response.results ?? []);
setSelected(0);
}
})
.catch(() => {
if (id === requestId.current) {
setFiles([]);
}
});
}, 120);
return () => clearTimeout(timer);
}, [visible, mode, text, projectRoot, recentFiles]);
const items = useMemo(() => {
if (mode === 'commands') {
const list = commands ?? [];
const matched = text.trim() ? fuzzyFilter(text, list, command => `${command.category ? `${command.category}: ` : ''}${command.label}`, 60).map(result => result.item) : list.slice(0, 60);
return matched.map(command => ({key: command.id, kind: 'command', command}));
}
if (mode === 'files') {
return files.map(file => ({key: file.path, kind: 'file', path: file.path}));
}
if (mode === 'line') {
const line = Number.parseInt(text, 10);
return Number.isFinite(line) && line > 0 ? [{key: 'line', kind: 'line', line, label: `Go to line ${line}`}] : [{key: 'line-hint', kind: 'hint', label: 'Type a line number'}];
}
return [{key: 'symbol', kind: 'symbol', label: 'Open symbol picker in the editor'}];
}, [mode, text, commands, files]);
useEffect(() => {
setSelected(current => Math.min(current, Math.max(0, items.length - 1)));
}, [items]);
const run = useCallback(
item => {
if (!item || item.kind === 'hint') {
return;
}
onClose?.();
if (item.kind === 'command') {
item.command.run?.();
} else if (item.kind === 'file') {
onOpenFile?.(item.path);
} else if (item.kind === 'line') {
onGoToLine?.(item.line);
} else if (item.kind === 'symbol') {
onGoToSymbol?.(text);
}
},
[onClose, onOpenFile, onGoToLine, onGoToSymbol, text],
);
const onKeyPress = useCallback(
event => {
const key = event.nativeEvent.key;
if (key === 'ArrowDown') {
setSelected(current => Math.min(items.length - 1, current + 1));
} else if (key === 'ArrowUp') {
setSelected(current => Math.max(0, current - 1));
} else if (key === 'Escape') {
onClose?.();
} else if (key === 'Enter') {
run(items[selected]);
}
},
[items, selected, onClose, run],
);
if (!visible) {
return null;
}
return (
<Pressable style={styles.backdrop} onPress={onClose} testID="palette-backdrop">
<Pressable style={styles.palette} onPress={() => {}} testID="command-palette">
<Field
inputRef={inputRef}
autoFocus
value={query}
onChangeText={value => {
setQuery(value);
setSelected(0);
}}
onKeyPress={onKeyPress}
onSubmitEditing={() => run(items[selected])}
placeholder="Search files by name; > for commands, : for a line, @ for a symbol"
style={styles.input}
testID="palette-input"
/>
<ScrollView style={styles.list} keyboardShouldPersistTaps="always">
{items.length === 0 && <Text style={styles.empty}>{mode === 'files' && !text ? 'No recent files' : 'No matches'}</Text>}
{items.map((item, index) => (
<Pressable
key={item.key}
onPress={() => run(item)}
onHoverIn={() => setSelected(index)}
style={[styles.item, index === selected && styles.itemSelected]}
testID={`palette-item-${index}`}>
{item.kind === 'file' && (
<>
<FileIcon name={item.path.split('/').pop()} size={14} />
<Text style={styles.itemLabel} numberOfLines={1}>{item.path.split('/').pop()}</Text>
<Text style={styles.itemDetail} numberOfLines={1}>{item.path.includes('/') ? item.path.slice(0, item.path.lastIndexOf('/')) : ''}</Text>
</>
)}
{item.kind === 'command' && (
<>
<Text style={styles.itemLabel} numberOfLines={1}>
{item.command.category ? <Text style={styles.category}>{item.command.category}: </Text> : null}
{item.command.label}
</Text>
{item.command.keybinding ? <Text style={styles.keybinding}>{formatKeybinding(item.command.keybinding)}</Text> : null}
</>
)}
{(item.kind === 'line' || item.kind === 'symbol' || item.kind === 'hint') && <Text style={styles.itemLabel}>{item.label}</Text>}
</Pressable>
))}
</ScrollView>
</Pressable>
</Pressable>
);
}
const createStyles = colors => StyleSheet.create({
backdrop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
paddingTop: 60,
zIndex: 100,
backgroundColor: withAlpha('#000000', 0.15),
},
palette: {
width: 620,
maxWidth: '92%',
maxHeight: 460,
padding: 8,
borderRadius: 14,
...glass(colors, {variant: 'surface'}),
backgroundColor: colors.surface ?? withAlpha('#000', 0.6),
},
input: {
fontSize: 14,
paddingVertical: 8,
},
list: {
marginTop: 6,
},
item: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
height: 30,
paddingHorizontal: 10,
borderRadius: 8,
},
itemSelected: {
backgroundColor: withAlpha(colors.primary, 0.3),
},
itemLabel: {
flex: 1,
color: colors.onSurface,
fontSize: 13,
},
itemDetail: {
color: colors.onSurfaceVariant,
fontSize: 11.5,
maxWidth: '50%',
},
category: {
color: colors.onSurfaceVariant,
},
keybinding: {
color: colors.onSurfaceVariant,
fontSize: 12,
},
empty: {
color: colors.onSurfaceVariant,
fontSize: 12.5,
padding: 10,
textAlign: 'center',
},
});
import {Pressable, StyleSheet, Text, View} from 'react-native';
import React from 'react';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {CodeEditor} from '../CodeEditor';
import {SmallButton} from './ui';
import {Tabs} from './Tabs';
/**
* A tab bar over one Monaco instance. The page may show two of these side by
* side; the one that last had focus is the active group, and that is where
* the next file opens.
*/
export function EditorGroup({
group,
isActive,
canSplit,
hasOtherGroup,
buffers,
dirtyPaths,
gitByPath,
problemsByPath,
gutterChanges,
diff,
conflict,
appKeys,
sources,
projectRoot,
editorRef,
onActivateGroup,
onActivateTab,
onCloseTab,
onCloseOthers,
onCloseAll,
onMoveTab,
onSplit,
onMoveToOtherGroup,
onCloseGroup,
onChange,
onSave,
onCommand,
onOpenFile,
onCursor,
onDiagnostics,
onLoadError,
onCloseDiff,
onResolveConflict,
}) {
const styles = useThemedStyles(createStyles);
const path = group.active;
const buffer = path ? buffers[path] : null;
return (
<View style={[styles.group, isActive && hasOtherGroup && styles.groupActive]} testID={`editor-group-${group.id}`}>
<Tabs
tabs={group.tabs}
active={path}
isActiveGroup={isActive}
dirtyPaths={dirtyPaths}
gitByPath={gitByPath}
problemsByPath={problemsByPath}
canSplit={canSplit}
hasOtherGroup={hasOtherGroup}
onActivate={tab => {
onActivateGroup(group.id);
onActivateTab(tab, group.id);
}}
onClose={tab => onCloseTab(tab, group.id)}
onCloseOthers={tab => onCloseOthers(tab, group.id)}
onCloseAll={() => onCloseAll(group.id)}
onMove={(tab, to) => onMoveTab(tab, to, group.id)}
onSplit={onSplit}
onMoveToOtherGroup={onMoveToOtherGroup}
onCloseGroup={() => onCloseGroup(group.id)}
/>
{diff && (
<View style={styles.banner} testID="diff-banner">
<Text style={styles.bannerText}>Comparing {path?.split('/').pop()} with HEAD{diff.staged ? ' (staged)' : ''}</Text>
<SmallButton title="Close diff" onPress={onCloseDiff} testID="close-diff" />
</View>
)}
{conflict && (
<View style={[styles.banner, styles.conflict]} testID="conflict-banner">
<Text style={styles.bannerText}>This file changed on disk while you were editing it.</Text>
<SmallButton title="Reload from disk" tone="accent" onPress={() => onResolveConflict(path, 'reload')} testID="conflict-reload" />
<SmallButton title="Keep mine" onPress={() => onResolveConflict(path, 'keep')} testID="conflict-keep" />
</View>
)}
<Pressable style={styles.editorWrap} onPressIn={() => onActivateGroup(group.id)}>
<CodeEditor
ref={editorRef}
value={buffer?.contents ?? ''}
path={path}
projectRoot={projectRoot}
sources={sources}
appKeys={appKeys}
gitChanges={gutterChanges}
diffOriginal={diff ? diff.original : null}
onChange={onChange}
onSave={onSave}
onCommand={onCommand}
onOpenFile={onOpenFile}
onCursor={onCursor}
onDiagnostics={onDiagnostics}
onLoadError={onLoadError}
onFocus={() => onActivateGroup(group.id)}
style={styles.editor}
/>
{!path && (
<View style={styles.empty} pointerEvents="none" testID="editor-empty">
<Text style={styles.emptyTitle}>No file open</Text>
<Text style={styles.emptyHint}>Pick one from the tree, or press ⌘P</Text>
</View>
)}
</Pressable>
</View>
);
}
const createStyles = colors => StyleSheet.create({
group: {
flex: 1,
gap: 8,
minWidth: 0,
},
groupActive: {},
editorWrap: {
flex: 1,
position: 'relative',
},
editor: {
flex: 1,
borderRadius: 16,
},
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 10,
...glass(colors, {variant: 'tinted'}),
},
conflict: {
borderColor: '#d29922',
},
bannerText: {
flex: 1,
color: colors.onSurface,
fontSize: 12.5,
},
empty: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
emptyTitle: {
color: withAlpha(colors.onSurfaceVariant, 0.8),
fontSize: 20,
fontWeight: '700',
},
emptyHint: {
color: withAlpha(colors.onSurfaceVariant, 0.7),
fontSize: 12.5,
},
});
import {Pressable, StyleSheet, Text, View} from 'react-native';
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';
export function FileNode({projectRoot, folder, onOpenFile, itemsDeep}) {
const styles = useThemedStyles(createStyles);
const [children, setChildren] = useState([]);
const [isOpen, setIsOpen] = useState(false);
function handleFileSelect(file) {
if (file.isDirectory) {
if (isOpen) {
setIsOpen(false);
return setChildren([]);
}
setIsOpen(true);
return openDirectory();
}
return onOpenFile(file.fullPath);
}
async function openDirectory() {
const unsortedItems = await fileSystem.listFiles(projectRoot, folder.fullPath);
const sortedFiles = sortFiles(unsortedItems.contents ?? []);
setChildren(sortedFiles);
setIsOpen(true);
}
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} />
)}
<FileIcon name={folder.name} isDirectory={folder.isDirectory} isOpen={isOpen} />
<Text>{folder.name}</Text>
</Pressable>
{children?.map(subFile => (
<FileNode
projectRoot={projectRoot}
key={subFile.fullPath}
folder={subFile}
onOpenFile={onOpenFile}
itemsDeep={(itemsDeep ?? 0) + 1}
/>
))}
</View>
);
}
const createStyles = colors => StyleSheet.create({
editor: {
marginBottom: 4,
},
row: {
flexDirection: 'row',
gap: 4,
alignItems: 'center',
},
});
import {Field, GIT_COLORS, IconButton, SmallButton} from './ui';
import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import React, {useCallback, useMemo, useState} from 'react';
import {glass, useThemedStyles} from '../theme';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {useEffect, useState} from 'react';
import {FileIcon} from '../icons/FileIcon';
import {FileIcon} from '../icons/FileIcon';
import {FileNode} from './FileNode';
import {NativeModules} from 'react-native';
import {fileSystem} from '../fileSystem';
import {sortFiles} from './sortFiles';
import {Icon} from '../icons';
import {flattenTree} from './useFileTree';
export function FileTree({currentFile, onOpenFile, onSave, projectRoot, setProjectRoot, openedFiles, setOpenedFiles}) {
const INDENT = 14;
/**
* The project as rows. Folders expand in place; hovering a row shows what can
* be done to it, and creating or renaming happens inline where the row is —
* no dialogs. Colour comes from git: what is new, changed or gone.
*/
export function FileTree({
projectRoot,
tree,
currentFile,
openFiles = [],
dirtyPaths,
gitByPath,
onOpenFile,
onOpenFolder,
onCreateFile,
onCreateFolder,
onRename,
onDelete,
onCollapseAll,
}) {
const styles = useThemedStyles(createStyles);
const styles = useThemedStyles(createStyles);
const [files, setFiles] = useState([]);
const [editing, setEditing] = useState(null);
const [confirming, setConfirming] = useState(null);
const [hovered, setHovered] = useState(null);
const rows = useMemo(() => flattenTree(tree.children, tree.expanded), [tree.children, tree.expanded]);
useEffect(() => {
async function fetchFiles() {
if (!projectRoot) {
return;
// A folder is coloured by the most severe change inside it, so a change
// three levels down is visible while the folder is collapsed.
const folderState = useMemo(() => {
const states = {};
for (const [path, state] of Object.entries(gitByPath ?? {})) {
const parts = path.split('/');
for (let index = 1; index < parts.length; index++) {
const folder = parts.slice(0, index).join('/');
states[folder] = mostSevere(states[folder], state);
}
}
}
return states;
}, [gitByPath]);
const unsortedFiles = await fileSystem.listFiles(projectRoot, '');
const sortedFiles = sortFiles(unsortedFiles.contents ?? []);
setFiles(sortedFiles);
const startCreate = useCallback(
(parent, kind) => {
tree.expand(parent);
setEditing({mode: kind, parent, value: ''});
setConfirming(null);
},
[tree],
);
const commitEdit = useCallback(async () => {
if (!editing) {
return;
}
}
fetchFiles();
}, [projectRoot]);
const value = editing.value.trim();
setEditing(null);
function handleFileSelect(file) {
if (file.isDirectory) {
return openDirectory(file);
if (!value) {
return;
}
}
return onOpenFile(file.fullPath);
if (editing.mode === 'file') {
await onCreateFile?.(editing.parent ? `${editing.parent}/${value}` : value);
} else if (editing.mode === 'folder') {
await onCreateFolder?.(editing.parent ? `${editing.parent}/${value}` : value);
} else if (editing.mode === 'rename') {
const parent = editing.path.includes('/') ? editing.path.slice(0, editing.path.lastIndexOf('/')) : '';
const to = parent ? `${parent}/${value}` : value;
if (to !== editing.path) {
await onRename?.(editing.path, to, editing.isDirectory);
}
}
}
}, [editing, onCreateFile, onCreateFolder, onRename]);
async function openDirectory(file) {
const pathToOpen = file.fullPath;
const onEditKey = useCallback(
event => {
if (event.nativeEvent.key === 'Escape') {
setEditing(null);
}
},
[],
);
const response = await fileSystem.listFiles(projectRoot, pathToOpen);
const folderItems = response.contents ?? [];
const projectName = projectRoot?.split('/').filter(Boolean).pop() ?? '';
file.items = folderItems;
return (
<View style={styles.panel} testID="file-tree">
<View style={styles.header}>
<Pressable onPress={onOpenFolder} style={styles.projectName} tooltip={projectRoot ?? 'Open a folder'}>
<Text style={styles.projectNameText} numberOfLines={1}>{projectName || 'No folder'}</Text>
</Pressable>
let updatedFiles = [...files];
updatedFiles = updatedFiles.map(f => {
if (f.name === file.name) {
return file;
}
{projectRoot && (
<View style={styles.headerActions}>
<IconButton glyph="+" label="New file" onPress={() => startCreate('', 'file')} testID="tree-new-file" />
<IconButton glyph="⊞" label="New folder" onPress={() => startCreate('', 'folder')} testID="tree-new-folder" />
<IconButton glyph="⟳" label="Refresh" onPress={tree.refresh} />
<IconButton glyph="⇈" label="Collapse all" onPress={onCollapseAll} />
</View>
)}
</View>
return f;
});
setFiles(updatedFiles);
}
{!projectRoot && (
<SmallButton title="Open folder…" tone="accent" onPress={onOpenFolder} style={styles.openButton} />
)}
async function openFolder() {
const path = await NativeModules.FolderPicker.pick();
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{editing && editing.mode !== 'rename' && editing.parent === '' && (
<InlineEditor styles={styles} depth={0} editing={editing} setEditing={setEditing} onSubmit={commitEdit} onKeyPress={onEditKey} />
)}
if (!path) {
return;
}
{rows.map(row => {
const isCurrent = row.fullPath === currentFile;
const isOpen = openFiles.includes(row.fullPath);
const isDirty = dirtyPaths?.has(row.fullPath);
const git = row.isDirectory ? folderState[row.fullPath] : gitByPath?.[row.fullPath];
const color = gitColor(git);
const isHovered = hovered === row.fullPath;
const isRenaming = editing?.mode === 'rename' && editing.path === row.fullPath;
const isConfirming = confirming === row.fullPath;
setProjectRoot(path);
}
return (
<View key={row.fullPath}>
{isRenaming ? (
<InlineEditor styles={styles} depth={row.depth} editing={editing} setEditing={setEditing} onSubmit={commitEdit} onKeyPress={onEditKey} icon={<FileIcon name={row.name} isDirectory={row.isDirectory} isOpen={row.isOpen} />} />
) : (
<Pressable
onPress={() => (row.isDirectory ? tree.toggle(row.fullPath) : onOpenFile?.(row.fullPath))}
onHoverIn={() => setHovered(row.fullPath)}
onHoverOut={() => setHovered(current => (current === row.fullPath ? null : current))}
style={[styles.row, {paddingLeft: 4 + row.depth * INDENT}, isCurrent && styles.rowCurrent, isOpen && !isCurrent && styles.rowOpen]}
testID={`tree-row-${row.fullPath}`}>
<View style={styles.chevron}>
{row.isDirectory && <Icon name={row.isOpen ? 'chevron-down' : 'chevron-right'} size={12} color={styles.chevronColor.color} />}
</View>
<FileIcon name={row.name} isDirectory={row.isDirectory} isOpen={row.isOpen} />
<Text style={[styles.name, color && {color}, isCurrent && styles.nameCurrent]} numberOfLines={1}>
{row.name}
</Text>
{isDirty && <View style={styles.dirtyDot} />}
{git && !row.isDirectory && <Text style={[styles.gitLetter, {color}]}>{gitLetter(git)}</Text>}
{isConfirming ? (
<View style={styles.confirm}>
<Text style={styles.confirmText}>Delete?</Text>
<SmallButton title="Yes" tone="danger" onPress={() => { setConfirming(null); onDelete?.(row.fullPath, row.isDirectory); }} testID="tree-confirm-delete" />
<SmallButton title="No" onPress={() => setConfirming(null)} />
</View>
) : (
(isHovered || isCurrent) && (
<View style={styles.rowActions}>
{row.isDirectory && <IconButton glyph="+" label="New file" onPress={() => startCreate(row.fullPath, 'file')} />}
{row.isDirectory && <IconButton glyph="⊞" label="New folder" onPress={() => startCreate(row.fullPath, 'folder')} />}
<IconButton glyph="✎" label="Rename" size={12} onPress={() => { setConfirming(null); setEditing({mode: 'rename', path: row.fullPath, value: row.name, isDirectory: row.isDirectory}); }} testID={`tree-rename-${row.fullPath}`} />
<IconButton glyph="✕" label="Delete" size={12} tone="danger" onPress={() => setConfirming(row.fullPath)} testID={`tree-delete-${row.fullPath}`} />
</View>
)
)}
</Pressable>
)}
{editing && editing.mode !== 'rename' && editing.parent === row.fullPath && row.isDirectory && (
<InlineEditor styles={styles} depth={row.depth + 1} editing={editing} setEditing={setEditing} onSubmit={commitEdit} onKeyPress={onEditKey} />
)}
</View>
);
})}
{projectRoot && rows.length === 0 && !tree.loading.has('') && (
<Text style={styles.emptyText}>This folder is empty.</Text>
)}
</ScrollView>
</View>
);
}
function InlineEditor({styles, depth, editing, setEditing, onSubmit, onKeyPress, icon}) {
return (
return (
<ScrollView style={styles.editor}>
<Pressable onPress={() => openFolder()} style={[styles.openFoler, !projectRoot && styles.noProjectRootRow]}>
<Text style={[styles.openFolderText, !projectRoot && styles.noProjectRootRowText]}>Open folder</Text>
</Pressable>
{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.row, styles.editRow, {paddingLeft: 4 + depth * INDENT}]}>
<View style={styles.chevronSpacer} />
<View style={styles.chevron} />
<FileIcon name={file.name} />
<Text>{file.name}</Text>
</Pressable>
)}
{file.isDirectory && (
<FileNode
projectRoot={projectRoot}
key={file.fullPath}
folder={file}
onOpenFile={onOpenFile}
itemsDeep={0}
{icon ?? <Text style={styles.editGlyph}>{editing.mode === 'folder' ? '⊞' : '+'}</Text>}
<Field
autoFocus
value={editing.value}
onChangeText={value => setEditing(current => (current ? {...current, value} : current))}
onSubmitEditing={onSubmit}
onKeyPress={onKeyPress}
placeholder={editing.mode === 'folder' ? 'folder name' : editing.mode === 'file' ? 'file name' : 'new name'}
style={styles.editField}
testID="tree-inline-input"
/>
/>
)}
<IconButton glyph="✓" label="Confirm" onPress={onSubmit} testID="tree-inline-confirm" />
<IconButton glyph="✕" label="Cancel" onPress={() => setEditing(null)} />
</View>
</View>
))}
</ScrollView>
);
);
}
}
const SEVERITY = {conflict: 5, deleted: 4, added: 3, untracked: 3, renamed: 2, modified: 1};
export function gitKind(state) {
if (!state) {
return null;
}
const {index = ' ', worktree = ' '} = state;
if (index === 'U' || worktree === 'U' || (index === 'A' && worktree === 'A') || (index === 'D' && worktree === 'D')) {
return 'conflict';
}
if (index === '?' || worktree === '?') {
return 'untracked';
}
if (worktree === 'D' || index === 'D') {
return 'deleted';
}
if (index === 'A') {
return 'added';
}
if (index === 'R' || index === 'C') {
return 'renamed';
}
if (worktree === 'M' || index === 'M' || worktree === 'T' || index === 'T') {
return 'modified';
}
return null;
}
function mostSevere(a, b) {
const kindA = typeof a === 'string' ? a : gitKind(a);
const kindB = typeof b === 'string' ? b : gitKind(b);
if (!kindA) {
return kindB;
}
if (!kindB) {
return kindA;
}
return (SEVERITY[kindA] ?? 0) >= (SEVERITY[kindB] ?? 0) ? kindA : kindB;
}
function gitColor(state) {
const kind = typeof state === 'string' ? state : gitKind(state);
return kind ? GIT_COLORS[kind] : null;
}
export function gitLetter(state) {
const kind = gitKind(state);
return {untracked: 'U', added: 'A', modified: 'M', deleted: 'D', renamed: 'R', conflict: '!'}[kind] ?? '';
}
const createStyles = colors => StyleSheet.create({
const createStyles = colors => StyleSheet.create({
noProjectRootRow: {
panel: {
flex: 1,
...glass(colors, {variant: 'subtle'}),
marginBottom: 16,
marginTop: 16,
borderRadius: 16,
borderRadius: 16,
padding: 16,
backgroundColor: colors.primary,
overflow: 'hidden',
},
header: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 12,
paddingTop: 10,
paddingBottom: 6,
gap: 4,
},
},
noProjectRootRowText: {
color: colors.onPrimary,
textAlign: 'center',
projectName: {
flex: 1,
},
},
openFoler: {
projectNameText: {
color: colors.onSurface,
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
},
headerActions: {
flexDirection: 'row',
},
openButton: {
marginHorizontal: 12,
marginBottom: 16,
marginBottom: 8,
alignItems: 'center',
},
scroll: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 8,
paddingBottom: 12,
},
},
row: {
row: {
flexDirection: 'row',
flexDirection: 'row',
gap: 4,
alignItems: 'center',
alignItems: 'center',
marginBottom: 4,
gap: 5,
height: 24,
paddingRight: 4,
borderRadius: 6,
},
rowCurrent: {
backgroundColor: withAlpha(colors.primary, 0.22),
},
rowOpen: {
backgroundColor: withAlpha(colors.onSurface, 0.05),
},
chevron: {
width: 12,
alignItems: 'center',
},
chevronColor: {
color: colors.onSurfaceVariant,
},
name: {
flex: 1,
color: colors.onSurface,
fontSize: 13,
},
nameCurrent: {
fontWeight: '600',
},
},
chevronSpacer: {
dirtyDot: {
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: colors.onSurface,
opacity: 0.8,
},
gitLetter: {
fontSize: 11,
fontWeight: '700',
width: 12,
textAlign: 'center',
},
rowActions: {
flexDirection: 'row',
},
confirm: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
confirmText: {
color: colors.onSurfaceVariant,
fontSize: 11,
},
editRow: {
height: 28,
},
editGlyph: {
color: colors.onSurfaceVariant,
width: 16,
width: 16,
textAlign: 'center',
},
},
editor: {
...glass(colors, {variant: 'subtle'}),
marginBottom: 16,
marginTop: 16,
padding: 16,
borderRadius: 16,
editField: {
flex: 1,
flex: 1,
paddingVertical: 2,
paddingHorizontal: 6,
fontSize: 13,
},
emptyText: {
color: colors.onSurfaceVariant,
fontSize: 12,
padding: 8,
},
},
});
});
import {EmptyState, Field, IconButton, SmallButton} from './ui';
import {Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {glass, useThemedStyles} from '../theme';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {useEffect, useState} from 'react';
import {FileIcon} from '../icons/FileIcon';
import {fileSystem} from '../fileSystem';
import {fileSystem} from '../fileSystem';
import {useRef} from 'react';
export function SearchSidebar({currentFile, onOpenFile, onSave, projectRoot, setProjectRoot, openedFiles, setOpenedFiles}) {
const styles = useThemedStyles(createStyles);
const DEBOUNCE_MS = 200;
/**
* Search across the project: by file name, or through file contents with
* every match shown on its line. Content results group by file and open at
* the line. A replacement, when typed, applies to one file or to all of them.
*/
export function SearchSidebar({projectRoot, onOpenFile, onReplaced, onError, initialTerm = ''}) {
const styles = useThemedStyles(createStyles);
const [term, setTerm] = useState(initialTerm);
const [mode, setMode] = useState('code');
const [replacement, setReplacement] = useState('');
const [showReplace, setShowReplace] = useState(false);
const [flags, setFlags] = useState({caseSensitive: false, wholeWord: false, regex: false});
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const [collapsed, setCollapsed] = useState(() => new Set());
const [excluded, setExcluded] = useState(() => new Set());
const requestId = useRef(0);
const inputRef = useRef(null);
const inputRef = useRef(null);
const [searchTerm, setSearchTerm] = useState('');
const [searchingTerm, setSearchingTerm] = useState('files');
const [searchResults, setSearchResults] = useState([]);
const search = useCallback(async () => {
if (!projectRoot || !term) {
setResults([]);
return;
}
const id = ++requestId.current;
setSearching(true);
try {
const response = await fileSystem.searchFiles(projectRoot, term, mode, flags);
if (id === requestId.current) {
setResults(response.results ?? []);
setExcluded(new Set());
}
} catch (error) {
if (id === requestId.current) {
setResults([]);
onError?.(error);
}
} finally {
if (id === requestId.current) {
setSearching(false);
}
}
}, [projectRoot, term, mode, flags, onError]);
useEffect(() => {
const timer = setTimeout(search, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
useEffect(() => {
async function search() {
if (!searchTerm) {
setSearchResults([]);
if (initialTerm) {
setTerm(initialTerm);
}
}, [initialTerm]);
const grouped = useMemo(() => {
if (mode !== 'code') {
return [];
}
const byFile = new Map();
for (const result of results) {
if (!byFile.has(result.path)) {
byFile.set(result.path, []);
}
byFile.get(result.path).push(result);
}
return [...byFile.entries()].map(([path, matches]) => ({path, matches}));
}, [results, mode]);
const replaceIn = useCallback(
async paths => {
if (!term) {
return;
return;
}
}
const response = await fileSystem.searchFiles(projectRoot, searchTerm, searchingTerm);
setSearchResults(response.results ?? []);
try {
const outcome = await fileSystem.replace(projectRoot, term, replacement, {...flags, paths});
onReplaced?.(outcome);
search();
} catch (error) {
onError?.(error);
}
}
},
[projectRoot, term, replacement, flags, onReplaced, onError, search],
);
const includedPaths = grouped.map(group => group.path).filter(path => !excluded.has(path));
const totalMatches = grouped.reduce((sum, group) => sum + (excluded.has(group.path) ? 0 : group.matches.length), 0);
const toggleFlag = flag => setFlags(current => ({...current, [flag]: !current[flag]}));
return (
<View style={styles.panel} testID="search-sidebar">
<View style={styles.modes}>
<ModeButton label="Contents" active={mode === 'code'} onPress={() => setMode('code')} styles={styles} />
<ModeButton label="File names" active={mode === 'files'} onPress={() => setMode('files')} styles={styles} />
{mode === 'code' && (
<IconButton glyph={showReplace ? '▾' : '▸'} label="Toggle replace" onPress={() => setShowReplace(value => !value)} testID="search-toggle-replace" />
)}
</View>
<View style={styles.inputRow}>
<Field
inputRef={inputRef}
autoFocus
value={term}
onChangeText={setTerm}
onSubmitEditing={search}
placeholder={mode === 'code' ? 'Search in files' : 'Search file names'}
style={styles.input}
testID="search-input"
/>
{mode === 'code' && (
<View style={styles.flags}>
<FlagButton label="Aa" tooltip="Match case" active={flags.caseSensitive} onPress={() => toggleFlag('caseSensitive')} styles={styles} />
<FlagButton label="ab" tooltip="Whole word" active={flags.wholeWord} onPress={() => toggleFlag('wholeWord')} styles={styles} underline />
<FlagButton label=".*" tooltip="Regular expression" active={flags.regex} onPress={() => toggleFlag('regex')} styles={styles} />
</View>
)}
</View>
{mode === 'code' && showReplace && (
<View style={styles.inputRow}>
<Field value={replacement} onChangeText={setReplacement} placeholder="Replace" style={styles.input} testID="replace-input" />
<SmallButton
title={`Replace all${totalMatches ? ` (${totalMatches})` : ''}`}
tone="accent"
disabled={!term || totalMatches === 0}
onPress={() => replaceIn(includedPaths)}
testID="replace-all"
/>
</View>
)}
search();
}, [searchTerm, projectRoot, searchingTerm]);
<ScrollView style={styles.results} contentContainerStyle={styles.resultsContent}>
{term && !searching && results.length === 0 && <EmptyState title="No results" hint={mode === 'code' ? 'Try fewer words, or turn off whole word.' : undefined} />}
{mode === 'files' &&
results.map(result => (
<Pressable key={result.path} onPress={() => onOpenFile?.(result.path)} style={styles.fileRow} testID={`search-file-${result.path}`}>
<FileIcon name={result.path.split('/').pop()} size={14} />
<Text style={styles.fileName} numberOfLines={1}>{result.path.split('/').pop()}</Text>
<Text style={styles.fileDirectory} numberOfLines={1}>{result.path.includes('/') ? result.path.slice(0, result.path.lastIndexOf('/')) : ''}</Text>
</Pressable>
))}
{mode === 'code' &&
grouped.map(group => {
const isCollapsed = collapsed.has(group.path);
const isExcluded = excluded.has(group.path);
return (
<View key={group.path} style={[isExcluded && styles.excluded]}>
<Pressable
onPress={() => setCollapsed(current => toggleIn(current, group.path))}
style={styles.fileRow}
testID={`search-group-${group.path}`}>
<Text style={styles.chevron}>{isCollapsed ? '▸' : '▾'}</Text>
<FileIcon name={group.path.split('/').pop()} size={14} />
<Text style={styles.fileName} numberOfLines={1}>{group.path.split('/').pop()}</Text>
<Text style={styles.fileDirectory} numberOfLines={1}>{group.path.includes('/') ? group.path.slice(0, group.path.lastIndexOf('/')) : ''}</Text>
<Text style={styles.count}>{group.matches.length}</Text>
{showReplace && <IconButton glyph="⇄" label="Replace in this file" size={12} onPress={() => replaceIn([group.path])} testID={`replace-file-${group.path}`} />}
<IconButton glyph={isExcluded ? '↺' : '✕'} label={isExcluded ? 'Include file' : 'Exclude file'} size={12} onPress={() => setExcluded(current => toggleIn(current, group.path))} />
</Pressable>
{!isCollapsed &&
group.matches.map(match => (
<Pressable
key={`${match.line}:${match.column}`}
onPress={() => onOpenFile?.(group.path, match.line, match.column)}
style={styles.matchRow}
testID={`search-match-${group.path}-${match.line}`}>
<Text style={styles.lineNumber}>{match.line}</Text>
<MatchPreview text={match.text} column={match.column} length={match.length ?? term.length} replacement={showReplace ? replacement : null} styles={styles} />
</Pressable>
))}
</View>
);
})}
</ScrollView>
</View>
);
}
function MatchPreview({text, column, length, replacement, styles}) {
const start = Math.max(0, column - 1);
const lead = Math.max(0, start - 40);
const before = text.slice(lead, start);
const match = text.slice(start, start + length);
const after = text.slice(start + length, start + length + 80);
return (
<Text style={styles.preview} numberOfLines={1}>
{lead > 0 ? '…' : ''}
{before}
<Text style={[styles.matchText, replacement !== null && styles.matchStruck]}>{match}</Text>
{replacement !== null && <Text style={styles.replacementText}>{replacement}</Text>}
{after}
</Text>
);
}
function ModeButton({label, active, onPress, styles}) {
return (
return (
<ScrollView style={styles.editor}>
<TextInput ref={inputRef} style={styles.input} placeholder="Looking for something?" enableFocusRing={false} value={searchTerm} onChangeText={setSearchTerm} />
{searchResults.map((result, index) => (
<Pressable key={index} onPress={() => onOpenFile(result)}>
<Text style={styles.result}>{result}</Text>
<Pressable onPress={onPress} style={[styles.mode, active && styles.modeActive]}>
<Text style={[styles.modeText, active && styles.modeTextActive]}>{label}</Text>
</Pressable>
</Pressable>
))}
</ScrollView>
);
);
}
}
function FlagButton({label, tooltip, active, onPress, styles, underline}) {
return (
<Pressable onPress={onPress} tooltip={tooltip} accessibilityLabel={tooltip} style={[styles.flag, active && styles.flagActive]}>
<Text style={[styles.flagText, active && styles.flagTextActive, underline && {textDecorationLine: 'underline'}]}>{label}</Text>
</Pressable>
);
}
function toggleIn(set, value) {
const next = new Set(set);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
return next;
}
const createStyles = colors => StyleSheet.create({
const createStyles = colors => StyleSheet.create({
editor: {
panel: {
flex: 1,
...glass(colors, {variant: 'subtle'}),
...glass(colors, {variant: 'subtle'}),
marginBottom: 16,
marginBottom: 16,
marginTop: 16,
marginTop: 16,
padding: 16,
padding: 10,
borderRadius: 16,
borderRadius: 16,
flex: 1,
gap: 8,
},
modes: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
mode: {
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 8,
},
modeActive: {
...glass(colors, {variant: 'accent'}),
},
modeText: {
color: colors.onSurfaceVariant,
fontSize: 12,
fontWeight: '600',
},
modeTextActive: {
color: colors.onPrimary,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
},
input: {
input: {
padding: 8,
flex: 1,
},
flags: {
flexDirection: 'row',
gap: 2,
},
flag: {
width: 24,
height: 24,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
},
flagActive: {
backgroundColor: withAlpha(colors.primary, 0.3),
},
flagText: {
color: colors.onSurfaceVariant,
fontSize: 11,
fontFamily: 'Menlo',
},
flagTextActive: {
color: colors.onSurface,
},
results: {
flex: 1,
},
resultsContent: {
paddingBottom: 8,
},
fileRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 26,
paddingHorizontal: 4,
borderRadius: 6,
},
chevron: {
color: colors.onSurfaceVariant,
width: 10,
fontSize: 11,
},
fileName: {
color: colors.onSurface,
fontSize: 12.5,
fontWeight: '600',
},
fileDirectory: {
flex: 1,
color: colors.onSurfaceVariant,
fontSize: 11,
},
count: {
color: colors.onSurfaceVariant,
fontSize: 11,
minWidth: 18,
textAlign: 'center',
borderRadius: 8,
borderRadius: 8,
...glass(colors, {variant: 'subtle'}),
backgroundColor: withAlpha(colors.onSurface, 0.1),
paddingHorizontal: 4,
},
excluded: {
opacity: 0.4,
},
matchRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingLeft: 24,
paddingRight: 4,
height: 22,
borderRadius: 6,
},
lineNumber: {
color: colors.onSurfaceVariant,
fontSize: 11,
fontFamily: 'Menlo',
minWidth: 28,
textAlign: 'right',
},
preview: {
flex: 1,
color: colors.onSurface,
fontSize: 12,
fontFamily: 'Menlo',
},
matchText: {
backgroundColor: withAlpha(colors.primary, 0.35),
fontWeight: '600',
},
matchStruck: {
textDecorationLine: 'line-through',
backgroundColor: withAlpha('#f85149', 0.3),
},
replacementText: {
backgroundColor: withAlpha('#3fb950', 0.3),
fontWeight: '600',
},
},
});
});
import {IconButton, SEVERITY_COLORS} from './ui';
import {PanResponder, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {FileIcon} from '../icons/FileIcon';
import {gitKind} from './FileTree';
import {GIT_COLORS} from './ui';
/**
* One editor group's tabs. A tab shows its file's name, a dot while unsaved,
* its git colour and an error count; it closes from its own button or the
* shortcut, and drags sideways to reorder. The trailing "⋯" is the group's
* menu: close others, close all, split, and where there are two groups, move
* to the other one.
*/
export function Tabs({
tabs,
active,
isActiveGroup,
dirtyPaths,
gitByPath,
problemsByPath,
canSplit,
hasOtherGroup,
onActivate,
onClose,
onCloseOthers,
onCloseAll,
onMove,
onSplit,
onMoveToOtherGroup,
onCloseGroup,
}) {
const styles = useThemedStyles(createStyles);
const [menuOpen, setMenuOpen] = useState(false);
const widths = useRef({});
// Tabs that share a file name show enough of their folder to tell apart.
const labels = useMemo(() => disambiguate(tabs), [tabs]);
const dropIndex = useCallback(
(path, dx) => {
const from = tabs.indexOf(path);
let offset = dx;
let index = from;
if (dx > 0) {
while (index < tabs.length - 1 && offset > (widths.current[tabs[index + 1]] ?? 100) / 2) {
offset -= widths.current[tabs[index + 1]] ?? 100;
index++;
}
} else {
while (index > 0 && -offset > (widths.current[tabs[index - 1]] ?? 100) / 2) {
offset += widths.current[tabs[index - 1]] ?? 100;
index--;
}
}
return index;
},
[tabs],
);
return (
<View style={[styles.bar, isActiveGroup && styles.barActive]} testID="tabs">
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs} keyboardShouldPersistTaps="always">
{tabs.map(path => (
<Tab
key={path}
path={path}
label={labels[path]}
isActive={path === active}
isDirty={dirtyPaths?.has(path)}
gitColor={gitColorFor(gitByPath?.[path])}
problems={problemsByPath?.[path]}
onActivate={() => onActivate(path)}
onClose={() => onClose(path)}
onDrop={dx => {
const to = dropIndex(path, dx);
if (to !== tabs.indexOf(path)) {
onMove?.(path, to);
}
}}
onLayout={event => {
widths.current[path] = event.nativeEvent.layout.width;
}}
styles={styles}
/>
))}
</ScrollView>
{tabs.length > 0 && (
<View style={styles.menuAnchor}>
<IconButton glyph="⋯" label="Tab actions" onPress={() => setMenuOpen(open => !open)} testID="tabs-menu" />
{menuOpen && (
<View style={styles.menu} testID="tabs-menu-open">
<MenuItem label="Close" onPress={() => { setMenuOpen(false); active && onClose(active); }} styles={styles} />
<MenuItem label="Close others" onPress={() => { setMenuOpen(false); active && onCloseOthers?.(active); }} styles={styles} />
<MenuItem label="Close all" onPress={() => { setMenuOpen(false); onCloseAll?.(); }} styles={styles} />
{canSplit && <MenuItem label="Split right" onPress={() => { setMenuOpen(false); onSplit?.(); }} styles={styles} />}
{hasOtherGroup && active && <MenuItem label="Move to other group" onPress={() => { setMenuOpen(false); onMoveToOtherGroup?.(active); }} styles={styles} />}
{hasOtherGroup && <MenuItem label="Close group" onPress={() => { setMenuOpen(false); onCloseGroup?.(); }} styles={styles} />}
</View>
)}
</View>
)}
</View>
);
}
function MenuItem({label, onPress, styles}) {
const [hovered, setHovered] = useState(false);
return (
<Pressable onPress={onPress} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} style={[styles.menuItem, hovered && styles.menuItemHovered]}>
<Text style={styles.menuItemText}>{label}</Text>
</Pressable>
);
}
function Tab({path, label, isActive, isDirty, gitColor, problems, onActivate, onClose, onDrop, onLayout, styles}) {
const [hovered, setHovered] = useState(false);
const [dragX, setDragX] = useState(0);
const dragging = useRef(false);
// A press that moves becomes a drag; one that does not is a click. The tab
// follows the pointer and, on release, is reordered by how far it went.
const responder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, gesture) => Math.abs(gesture.dx) > 6 && Math.abs(gesture.dx) > Math.abs(gesture.dy),
onPanResponderGrant: () => {
dragging.current = true;
},
onPanResponderMove: (_, gesture) => setDragX(gesture.dx),
onPanResponderRelease: (_, gesture) => {
dragging.current = false;
setDragX(0);
onDrop(gesture.dx);
},
onPanResponderTerminate: () => {
dragging.current = false;
setDragX(0);
},
}),
[onDrop],
);
const errors = problems?.errors ?? 0;
const warnings = problems?.warnings ?? 0;
const name = path.split('/').pop();
return (
<Pressable
{...responder.panHandlers}
onPress={() => {
if (!dragging.current) {
onActivate();
}
}}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
onLayout={onLayout}
style={[styles.tab, isActive && styles.tabActive, dragX !== 0 && {transform: [{translateX: dragX}], zIndex: 10, opacity: 0.85}]}
testID={`tab-${path}`}
tooltip={path}>
<FileIcon name={name} size={14} />
<Text style={[styles.tabText, isActive && styles.tabTextActive, gitColor && {color: gitColor}, errors > 0 && {color: SEVERITY_COLORS.error}]} numberOfLines={1}>
{label}
</Text>
{(errors > 0 || warnings > 0) && (
<Text style={[styles.problems, {color: errors > 0 ? SEVERITY_COLORS.error : SEVERITY_COLORS.warning}]}>
{errors > 0 ? errors : warnings}
</Text>
)}
{isDirty && !hovered ? (
<View style={styles.dirtyDot} testID={`tab-dirty-${path}`} />
) : (
<IconButton glyph="×" label={`Close ${name}`} size={15} onPress={onClose} style={[styles.close, !hovered && !isActive && styles.closeHidden]} testID={`tab-close-${path}`} />
)}
</Pressable>
);
}
function gitColorFor(state) {
const kind = gitKind(state);
return kind ? GIT_COLORS[kind] : null;
}
/** `a/index.js` and `b/index.js` show as `a/index.js` and `b/index.js`, not twice `index.js`. */
export function disambiguate(paths) {
const byName = {};
for (const path of paths) {
const name = path.split('/').pop();
(byName[name] ??= []).push(path);
}
const labels = {};
for (const [name, group] of Object.entries(byName)) {
if (group.length === 1) {
labels[group[0]] = name;
continue;
}
for (const path of group) {
const parts = path.split('/');
let depth = 2;
let label = parts.slice(-depth).join('/');
while (group.some(other => other !== path && other.split('/').slice(-depth).join('/') === label) && depth < parts.length) {
depth++;
label = parts.slice(-depth).join('/');
}
labels[path] = label;
}
}
return labels;
}
const createStyles = colors => StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 34,
paddingHorizontal: 4,
borderRadius: 12,
...glass(colors, {variant: 'subtle'}),
opacity: 0.85,
},
barActive: {
opacity: 1,
},
tabs: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
paddingVertical: 3,
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingLeft: 10,
paddingRight: 4,
height: 28,
borderRadius: 9,
maxWidth: 240,
},
tabActive: {
...glass(colors, {variant: 'accent'}),
},
tabText: {
color: colors.onSurface,
fontSize: 12.5,
flexShrink: 1,
},
tabTextActive: {
color: colors.onPrimary,
fontWeight: '600',
},
problems: {
fontSize: 10,
fontWeight: '700',
},
dirtyDot: {
width: 8,
height: 8,
borderRadius: 4,
marginHorizontal: 7,
backgroundColor: colors.onSurface,
},
close: {
width: 20,
height: 20,
},
closeHidden: {
opacity: 0,
},
menuAnchor: {
position: 'relative',
zIndex: 20,
},
menu: {
position: 'absolute',
top: 26,
right: 0,
minWidth: 180,
padding: 4,
borderRadius: 10,
...glass(colors, {variant: 'surface'}),
backgroundColor: colors.surface ?? withAlpha(colors.onSurface, 0.1),
zIndex: 30,
},
menuItem: {
paddingVertical: 6,
paddingHorizontal: 10,
borderRadius: 6,
},
menuItemHovered: {
backgroundColor: withAlpha(colors.primary, 0.25),
},
menuItemText: {
color: colors.onSurface,
fontSize: 12.5,
},
});
import { TERMINAL_OPTIONS, XTERM_CDN, XTERM_FIT_CDN, terminalTheme } from './terminalTheme';
import {TERMINAL_OPTIONS, XTERM_CDN, XTERM_FIT_CDN, terminalTheme} from './terminalTheme';
import {useCallback, useEffect, useRef, useState} from 'react';
import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {CDN_SOURCES} from './vendor';
import {StyleSheet} from 'react-native';
import {StyleSheet} from 'react-native';
import {WebView} from 'react-native-webview';
import {WebView} from 'react-native-webview';
import {fileSystem} from '../fileSystem';
import {fileSystem} from '../fileSystem';
* the frame is blocked as mixed content; from React Native it is just a socket.
* the frame is blocked as mixed content; from React Native it is just a socket.
* So the page is the screen and the keyboard, and nothing else.
* So the page is the screen and the keyboard, and nothing else.
*/
*/
export function Terminal({projectRoot, onSearch, style}) {
export const Terminal = forwardRef(function Terminal({projectRoot, onCommand, onTitle, onExit, appKeys, sources = CDN_SOURCES, style}, ref) {
const colors = useTheme();
const colors = useTheme();
const scheme = useColorScheme() ?? 'light';
const scheme = useColorScheme() ?? 'light';
// reload the frame and take the scrollback with it.
// reload the frame and take the scrollback with it.
const source = useRef(null);
const source = useRef(null);
source.current ??= {
source.current ??= {
html: terminalHtml(terminalTheme(colors, scheme)),
html: terminalHtml(terminalTheme(colors, scheme), sources),
baseUrl: `${XTERM_CDN}/`,
baseUrl: `${sources.xterm}/`,
};
};
const inject = useCallback(script => {
const inject = useCallback(script => {
if (message.type === 'exit') {
if (message.type === 'exit') {
inject(`window.notice(${jsString('[shell exited — press any key]')});`);
inject(`window.notice(${jsString('[shell exited — press any key]')});`);
onExit?.(message);
}
if (message.type === 'ready') {
onTitle?.(message.cwd?.split('/').pop() || 'shell');
}
}
};
};
socket.current = null;
socket.current = null;
}
}
};
};
}, [projectRoot, inject, send]);
}, [projectRoot, inject, send, onExit, onTitle]);
// The server came back: a panel that lost its shell gets a new one without
// waiting for a keypress, so the prompt is there when the user looks.
useEffect(() => {
if (size.current && !socket.current) {
inject('window.reset();');
connect();
}
}, [connect, inject]);
useEffect(() => {
if (!loaded || !appKeys) {
return;
}
inject(`window.setAppKeys(${JSON.stringify(appKeys)});`);
}, [loaded, appKeys, inject]);
useImperativeHandle(ref, () => ({
focus: () => inject('window.focusTerminal();'),
fit: () => inject('window.fit();'),
write: text => send({type: 'input', data: text}),
clear: () => inject('window.clear();'),
}), [inject, send]);
const onMessage = useCallback(
const onMessage = useCallback(
event => {
event => {
send({type: 'input', data: message.data});
send({type: 'input', data: message.data});
}
}
// Cmd+P is the app's, not the shell's — same hand-back as the editor.
// An app shortcut pressed while the shell has focus — same hand-back
// as the editor.
if (message.type === 'search') {
if (message.type === 'key') {
onSearch?.();
onCommand?.(message);
}
if (message.type === 'title') {
onTitle?.(message.title);
}
}
},
},
[connect, send, inject, onSearch],
[connect, send, inject, onCommand, onTitle],
);
);
// The palette arrives from a fetch, so it is pushed in rather than being the
// The palette arrives from a fetch, so it is pushed in rather than being the
}
}
/>
/>
);
);
}
});
/**
/**
* `injectJavaScript` takes source, not data. JSON is very nearly a subset of
* `injectJavaScript` takes source, not data. JSON is very nearly a subset of
.replace(/\u2029/g, '\\u2029');
.replace(/\u2029/g, '\\u2029');
}
}
function terminalHtml(theme) {
export function terminalHtml(theme, sources = CDN_SOURCES) {
return `<!doctype html>
return `<!doctype html>
<html>
<html>
<head>
<head>
<meta charset="utf-8" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="stylesheet" href="${XTERM_CDN}/css/xterm.css" />
<link rel="stylesheet" href="${sources.xterm}/css/xterm.css" />
<style>
<style>
/* A transparent WKWebView still paints whatever the document paints. */
/* A transparent WKWebView still paints whatever the document paints. */
html, body, #terminal {
html, body, #terminal {
</head>
</head>
<body>
<body>
<div id="terminal"></div>
<div id="terminal"></div>
<script src="${XTERM_CDN}/lib/xterm.js"></script>
<script src="${XTERM_FIT_CDN}/lib/addon-fit.js"></script>
<script>
<script>
// The libraries off the file server when it is up, the CDN otherwise —
// and the CDN again if the local copy fails to arrive.
var SOURCES = ${JSON.stringify(sources)};
var CDN = ${JSON.stringify({xterm: XTERM_CDN, xtermFit: XTERM_FIT_CDN})};
function loadScript(src, onload, onerror) {
var script = document.createElement('script');
script.src = src;
script.onload = onload;
script.onerror = onerror;
document.head.appendChild(script);
}
function loadLibraries(base, onload, onerror) {
loadScript(base.xterm + '/lib/xterm.js', function () {
loadScript(base.xtermFit + '/lib/addon-fit.js', onload, onerror);
}, onerror);
}
loadLibraries(SOURCES, start, function () {
if (SOURCES.xterm === CDN.xterm) {
return;
}
loadLibraries(CDN, start, function () {});
});
function start() {
var term = new Terminal(Object.assign(
var term = new Terminal(Object.assign(
${JSON.stringify(TERMINAL_OPTIONS)},
${JSON.stringify(TERMINAL_OPTIONS)},
{theme: ${JSON.stringify(theme)}}
{theme: ${JSON.stringify(theme)}}
window.write = function (data) { term.write(data); };
window.write = function (data) { term.write(data); };
window.notice = function (text) { term.write('\\r\\n\\x1b[2m' + text + '\\x1b[0m\\r\\n'); };
window.notice = function (text) { term.write('\\r\\n\\x1b[2m' + text + '\\x1b[0m\\r\\n'); };
window.reset = function () { term.reset(); };
window.reset = function () { term.reset(); };
window.clear = function () { term.clear(); };
window.setTheme = function (theme) { term.options.theme = theme; };
window.setTheme = function (theme) { term.options.theme = theme; };
window.focusTerminal = function () { term.focus(); };
window.fit = function () { report(); };
// The shell's own title, when it sets one — most prompts do — names the
// tab in the panel.
term.onTitleChange(function (title) { post({type: 'title', title: title}); });
// Cell size is what decides cols and rows, so the pty is only told a
// Cell size is what decides cols and rows, so the pty is only told a
// size the emulator has actually settled on. Reporting the same one
// size the emulator has actually settled on. Reporting the same one
term.onData(function (data) { post({type: 'input', data: data}); });
term.onData(function (data) { post({type: 'input', data: data}); });
// The app's shortcuts, as [{key, metaKey, ctrlKey, altKey, shiftKey}].
// Returning false leaves the key to us. Only Cmd+P is taken: every other
// Returning false leaves the key to us. Only these are taken: every other
// Cmd combination has to fall through to WebKit, which is what makes
// Cmd combination has to fall through to WebKit, which is what makes
// Cmd+C and Cmd+V work — xterm copies and pastes off the DOM events the
// Cmd+C and Cmd+V work — xterm copies and pastes off the DOM events the
// browser fires, not off the keystrokes.
// browser fires, not off the keystrokes.
var appKeys = [];
window.setAppKeys = function (keys) { appKeys = keys || []; };
function keyMatches(binding, event) {
return String(binding.key).toLowerCase() === String(event.key).toLowerCase() &&
Boolean(binding.metaKey) === event.metaKey &&
Boolean(binding.ctrlKey) === event.ctrlKey &&
Boolean(binding.altKey) === event.altKey &&
Boolean(binding.shiftKey) === event.shiftKey;
}
term.attachCustomKeyEventHandler(function (event) {
term.attachCustomKeyEventHandler(function (event) {
if (event.type === 'keydown' && event.metaKey && event.key === 'p') {
if (event.type !== 'keydown') {
return true;
}
// Ctrl+letter is the shell's — Ctrl+C above all. Only Cmd/Alt combos
// and Ctrl with a non-letter are candidates for the app.
var shellOwned = event.ctrlKey && !event.metaKey && !event.altKey && /^[a-z]$/i.test(event.key) && event.key.toLowerCase() !== 'g';
if (shellOwned) {
return true;
}
for (var index = 0; index < appKeys.length; index++) {
if (keyMatches(appKeys[index], event)) {
event.preventDefault();
event.preventDefault();
post({type: 'search'});
post({
type: 'key', key: event.key, code: event.code,
metaKey: event.metaKey, ctrlKey: event.ctrlKey, altKey: event.altKey, shiftKey: event.shiftKey,
});
return false;
return false;
}
}
}
return true;
return true;
}
}
report();
report();
}
</script>
</script>
</body>
</body>
</html>`;
</html>`;
import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import React, {useEffect, useRef, useState} from 'react';
import {glass, useThemedStyles, withAlpha} from '../theme';
import {IconButton} from './ui';
import {Terminal} from './Terminal';
/**
* Several shells, one showing. Every terminal stays mounted so its scrollback
* and running job survive a switch; the ones not showing are hidden, and told
* to re-measure when shown again because a hidden panel has no size.
*/
export function TerminalPanel({projectRoot, terminals, activeId, onActivate, onAdd, onClose, onRename, onCollapse, appKeys, sources, onCommand, style}) {
const styles = useThemedStyles(createStyles);
const refs = useRef({});
const [titles, setTitles] = useState({});
useEffect(() => {
// A terminal that was hidden has been laid out at zero; fit it now.
setTimeout(() => refs.current[activeId]?.fit?.(), 50);
}, [activeId]);
return (
<View style={[styles.panel, style]} testID="terminal-panel">
<View style={styles.bar}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
{terminals.map((terminal, index) => (
<TerminalTab
key={terminal.id}
title={titles[terminal.id] ?? terminal.title ?? `shell ${index + 1}`}
isActive={terminal.id === activeId}
onPress={() => onActivate(terminal.id)}
onClose={() => onClose(terminal.id)}
styles={styles}
testID={`terminal-tab-${terminal.id}`}
/>
))}
</ScrollView>
<IconButton glyph="+" label="New terminal" onPress={onAdd} testID="terminal-add" />
<IconButton glyph="⌄" label="Hide terminal" onPress={onCollapse} testID="terminal-collapse" />
</View>
<View style={styles.body}>
{terminals.map(terminal => (
<View key={terminal.id} style={[styles.terminal, terminal.id !== activeId && styles.hidden]}>
<Terminal
ref={instance => {
refs.current[terminal.id] = instance;
}}
projectRoot={projectRoot}
appKeys={appKeys}
sources={sources}
onCommand={onCommand}
onTitle={title => setTitles(current => (current[terminal.id] === title ? current : {...current, [terminal.id]: title}))}
/>
</View>
))}
</View>
</View>
);
}
function TerminalTab({title, isActive, onPress, onClose, styles, testID}) {
const [hovered, setHovered] = useState(false);
return (
<Pressable onPress={onPress} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} style={[styles.tab, isActive && styles.tabActive]} testID={testID}>
<Text style={[styles.tabText, isActive && styles.tabTextActive]} numberOfLines={1}>{title}</Text>
<IconButton glyph="×" label="Close terminal" size={14} onPress={onClose} style={[!hovered && !isActive && styles.closeHidden]} />
</Pressable>
);
}
const createStyles = colors => StyleSheet.create({
panel: {
borderRadius: 16,
overflow: 'hidden',
...glass(colors, {variant: 'subtle'}),
},
bar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingTop: 4,
height: 32,
},
tabs: {
flexDirection: 'row',
gap: 2,
alignItems: 'center',
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
height: 24,
paddingLeft: 10,
paddingRight: 2,
borderRadius: 8,
maxWidth: 200,
},
tabActive: {
backgroundColor: withAlpha(colors.primary, 0.25),
},
tabText: {
color: colors.onSurfaceVariant,
fontSize: 12,
},
tabTextActive: {
color: colors.onSurface,
fontWeight: '600',
},
closeHidden: {
opacity: 0,
},
body: {
flex: 1,
},
terminal: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
hidden: {
display: 'none',
},
});
import {Pressable, StyleSheet, Text, View} from 'react-native';
import React, {createContext, useCallback, useContext, useMemo, useRef, useState} from 'react';
import {glass, useThemedStyles} from '../theme';
const ToastContext = createContext(null);
const DURATION = {info: 4000, success: 3000, error: 8000};
/**
* Feedback for things that happen off-screen: a failed save, a finished
* replace, a server that went away. Errors linger; the rest fade. A toast may
* carry one action, which is how the conflict prompt offers "reload".
*/
export function ToastProvider({children}) {
const [toasts, setToasts] = useState([]);
const counter = useRef(0);
const dismiss = useCallback(id => {
setToasts(current => current.filter(toast => toast.id !== id));
}, []);
const show = useCallback(
(message, {kind = 'info', action = null, sticky = false} = {}) => {
const id = ++counter.current;
const toast = {id, message: String(message), kind, action};
setToasts(current => [...current.slice(-4), toast]);
if (!sticky) {
setTimeout(() => dismiss(id), DURATION[kind] ?? DURATION.info);
}
return id;
},
[dismiss],
);
const value = useMemo(() => ({show, dismiss}), [show, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastStack toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
// Outside a provider — in a test, say — a toast is a log line.
return context ?? {show: message => console.log(`[toast] ${message}`), dismiss: () => {}};
}
function ToastStack({toasts, onDismiss}) {
const styles = useThemedStyles(createStyles);
if (toasts.length === 0) {
return null;
}
return (
<View pointerEvents="box-none" style={styles.stack}>
{toasts.map(toast => (
<View key={toast.id} style={[styles.toast, styles[toast.kind]]} testID={`toast-${toast.kind}`}>
<Text style={styles.message} numberOfLines={4}>{toast.message}</Text>
{toast.action && (
<Pressable
onPress={() => {
toast.action.onPress();
onDismiss(toast.id);
}}
style={styles.action}>
<Text style={styles.actionText}>{toast.action.label}</Text>
</Pressable>
)}
<Pressable onPress={() => onDismiss(toast.id)} style={styles.close} accessibilityLabel="Dismiss">
<Text style={styles.closeText}>×</Text>
</Pressable>
</View>
))}
</View>
);
}
const createStyles = colors => StyleSheet.create({
stack: {
position: 'absolute',
right: 16,
bottom: 16,
gap: 8,
maxWidth: 420,
},
toast: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 12,
...glass(colors, {variant: 'surface'}),
},
info: {},
success: {borderColor: colors.primary},
error: {borderColor: colors.error ?? '#d33'},
message: {
flex: 1,
color: colors.onSurface,
fontSize: 13,
},
action: {
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 8,
...glass(colors, {variant: 'accent'}),
},
actionText: {
color: colors.onPrimary,
fontSize: 12,
fontWeight: '600',
},
close: {
paddingHorizontal: 4,
},
closeText: {
color: colors.onSurfaceVariant,
fontSize: 16,
},
});
import {IconButton, SmallButton} from './ui';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {glass, useThemedStyles, withAlpha} from '../theme';
import React from 'react';
import {formatKeybinding} from './keymap';
/** The Code page before a folder is open: open one, or go back to a recent one. */
export function Welcome({recentProjects = [], onOpenFolder, onOpenRecent, onForgetRecent, keybindings}) {
const styles = useThemedStyles(createStyles);
return (
<View style={styles.wrapper} testID="welcome">
<Text style={styles.title}>No project open</Text>
<Text style={styles.subtitle}>Open a folder to start editing. {keybindings?.openFolder ? formatKeybinding(keybindings.openFolder) : ''}</Text>
<SmallButton title="Open folder…" tone="accent" onPress={onOpenFolder} style={styles.open} testID="welcome-open" />
{recentProjects.length > 0 && (
<View style={styles.recent}>
<Text style={styles.recentTitle}>Recent</Text>
{recentProjects.map(path => (
<Pressable key={path} onPress={() => onOpenRecent(path)} style={styles.recentRow} testID={`recent-${path}`}>
<View style={{flex: 1}}>
<Text style={styles.recentName}>{path.split('/').filter(Boolean).pop()}</Text>
<Text style={styles.recentPath} numberOfLines={1}>{path}</Text>
</View>
<IconButton glyph="×" label="Remove from recent" onPress={() => onForgetRecent(path)} />
</Pressable>
))}
</View>
)}
</View>
);
}
const createStyles = colors => StyleSheet.create({
wrapper: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
gap: 8,
},
title: {
fontSize: 28,
color: colors.onSurfaceVariant,
fontWeight: 'bold',
opacity: 0.85,
},
subtitle: {
fontSize: 13,
color: colors.onSurfaceVariant,
},
open: {
marginTop: 8,
paddingHorizontal: 18,
paddingVertical: 8,
},
warning: {
marginTop: 12,
color: '#f85149',
fontSize: 12.5,
textAlign: 'center',
maxWidth: 420,
},
recent: {
marginTop: 24,
width: 420,
maxWidth: '100%',
padding: 10,
borderRadius: 14,
...glass(colors, {variant: 'subtle'}),
},
recentTitle: {
color: colors.onSurfaceVariant,
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.6,
textTransform: 'uppercase',
marginBottom: 6,
paddingHorizontal: 6,
},
recentRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 6,
paddingHorizontal: 8,
borderRadius: 8,
},
recentName: {
color: colors.onSurface,
fontSize: 13.5,
fontWeight: '600',
},
recentPath: {
color: withAlpha(colors.onSurfaceVariant, 0.9),
fontSize: 11.5,
},
});
/**
* Which files are open, in which editor group, and which one each group shows.
*
* A pure reducer: the page keeps the result in state and every keyboard
* shortcut, tab click and tree action goes through here, so the rules — a
* closed tab hands focus to its neighbour, a split copies the current file
* across, an emptied second group folds back into the first — live in one
* place and can be tested without a renderer.
*
* {groups: [{id, tabs: [path], active: path | null}], activeGroup: id}
*/
export const MAX_GROUPS = 2;
let nextId = 1;
function newGroup(tabs = [], active = null) {
return {id: nextId++, tabs, active: active ?? tabs[0] ?? null};
}
export function initialEditorState() {
const group = newGroup();
return {groups: [group], activeGroup: group.id};
}
export function activeGroup(state) {
return state.groups.find(group => group.id === state.activeGroup) ?? state.groups[0];
}
export function activeFile(state) {
return activeGroup(state)?.active ?? null;
}
/** Every open path, once, across all groups. */
export function openPaths(state) {
return [...new Set(state.groups.flatMap(group => group.tabs))];
}
function updateGroup(state, id, update) {
return {
...state,
groups: state.groups.map(group => (group.id === id ? {...group, ...update(group)} : group)),
};
}
/**
* A second group that has lost its last tab is removed; a single group stays
* even when empty, so the page always has somewhere to open the next file.
*/
function collapseEmpty(state) {
if (state.groups.length <= 1) {
return state;
}
const groups = state.groups.filter(group => group.tabs.length > 0);
if (groups.length === state.groups.length) {
return state;
}
const kept = groups.length ? groups : [state.groups[0]];
const activeGroup = kept.some(group => group.id === state.activeGroup) ? state.activeGroup : kept[0].id;
return {...state, groups: kept, activeGroup};
}
export function editorReducer(state, action) {
switch (action.type) {
case 'open': {
const id = action.groupId ?? state.activeGroup;
const next = updateGroup(state, id, group => ({
tabs: group.tabs.includes(action.path) ? group.tabs : [...group.tabs, action.path],
active: action.background ? group.active ?? action.path : action.path,
}));
return {...next, activeGroup: action.background ? state.activeGroup : id};
}
case 'activate': {
const id = action.groupId ?? state.groups.find(group => group.tabs.includes(action.path))?.id ?? state.activeGroup;
const next = updateGroup(state, id, group => ({
active: group.tabs.includes(action.path) ? action.path : group.active,
}));
return {...next, activeGroup: id};
}
case 'activateGroup': {
if (!state.groups.some(group => group.id === action.groupId)) {
return state;
}
return {...state, activeGroup: action.groupId};
}
case 'close': {
const id = action.groupId ?? state.activeGroup;
const next = updateGroup(state, id, group => {
const index = group.tabs.indexOf(action.path);
if (index === -1) {
return {};
}
const tabs = group.tabs.filter(tab => tab !== action.path);
let active = group.active;
// The tab to the right, then to the left — what a browser does.
if (group.active === action.path) {
active = tabs[Math.min(index, tabs.length - 1)] ?? null;
}
return {tabs, active};
});
return collapseEmpty(next);
}
case 'closeOthers': {
const id = action.groupId ?? state.activeGroup;
return updateGroup(state, id, group => ({
tabs: group.tabs.includes(action.path) ? [action.path] : group.tabs,
active: group.tabs.includes(action.path) ? action.path : group.active,
}));
}
case 'closeAll': {
const id = action.groupId ?? state.activeGroup;
return collapseEmpty(updateGroup(state, id, () => ({tabs: [], active: null})));
}
/** A file that vanished from disk, or was renamed away: closed everywhere. */
case 'closeEverywhere': {
const next = {
...state,
groups: state.groups.map(group => {
if (!group.tabs.includes(action.path)) {
return group;
}
const index = group.tabs.indexOf(action.path);
const tabs = group.tabs.filter(tab => tab !== action.path);
const active = group.active === action.path ? tabs[Math.min(index, tabs.length - 1)] ?? null : group.active;
return {...group, tabs, active};
}),
};
return collapseEmpty(next);
}
case 'rename': {
const swap = tab => (tab === action.from ? action.to : tab);
return {
...state,
groups: state.groups.map(group => ({
...group,
tabs: group.tabs.map(swap),
active: group.active === null ? null : swap(group.active),
})),
};
}
/** Everything under a renamed folder moves with it. */
case 'renameFolder': {
const prefix = `${action.from}/`;
const swap = tab => (tab === action.from || tab.startsWith(prefix) ? action.to + tab.slice(action.from.length) : tab);
return {
...state,
groups: state.groups.map(group => ({
...group,
tabs: group.tabs.map(swap),
active: group.active === null ? null : swap(group.active),
})),
};
}
case 'move': {
const id = action.groupId ?? state.activeGroup;
return updateGroup(state, id, group => {
const from = group.tabs.indexOf(action.path);
if (from === -1) {
return {};
}
const to = Math.max(0, Math.min(group.tabs.length - 1, action.to));
const tabs = [...group.tabs];
tabs.splice(from, 1);
tabs.splice(to, 0, action.path);
return {tabs};
});
}
case 'moveToGroup': {
const source = state.groups.find(group => group.tabs.includes(action.path));
const target = state.groups.find(group => group.id === action.groupId);
if (!source || !target || source.id === target.id) {
return state;
}
let next = editorReducer(state, {type: 'close', path: action.path, groupId: source.id});
next = editorReducer(next, {type: 'open', path: action.path, groupId: target.id});
return next;
}
case 'next':
case 'previous': {
const group = activeGroup(state);
if (!group || group.tabs.length < 2) {
return state;
}
const index = group.tabs.indexOf(group.active);
const step = action.type === 'next' ? 1 : -1;
const active = group.tabs[(index + step + group.tabs.length) % group.tabs.length];
return updateGroup(state, group.id, () => ({active}));
}
/** Split to the right, carrying the current file across so both sides show it. */
case 'split': {
if (state.groups.length >= MAX_GROUPS) {
return state;
}
const current = activeGroup(state);
const group = newGroup(current?.active ? [current.active] : [], current?.active ?? null);
return {...state, groups: [...state.groups, group], activeGroup: group.id};
}
case 'unsplit': {
if (state.groups.length <= 1) {
return state;
}
const [first, ...rest] = state.groups;
const tabs = [...first.tabs];
for (const group of rest) {
for (const tab of group.tabs) {
if (!tabs.includes(tab)) {
tabs.push(tab);
}
}
}
const active = activeGroup(state)?.active ?? first.active ?? tabs[0] ?? null;
return {...state, groups: [{...first, tabs, active}], activeGroup: first.id};
}
case 'restore': {
const groups = (action.groups ?? [])
.map(saved => newGroup([...new Set((saved.tabs ?? []).filter(Boolean))], saved.active ?? null))
.filter(group => group.tabs.length > 0)
.slice(0, MAX_GROUPS);
if (groups.length === 0) {
return initialEditorState();
}
const index = Math.min(action.activeIndex ?? 0, groups.length - 1);
return {groups, activeGroup: groups[index].id};
}
default:
return state;
}
}
/** The shape saved between launches; ids are not stable, so positions are used. */
export function serializeEditorState(state) {
return {
groups: state.groups.map(group => ({tabs: group.tabs, active: group.active})),
activeIndex: Math.max(0, state.groups.findIndex(group => group.id === state.activeGroup)),
};
}
/**
* Scoring for the palette and quick open: every character of the query has
* to appear in the candidate, in order. Matches on the start of a word, or a
* run of consecutive characters, score higher; gaps cost a little. Returns
* zero for no match, so callers filter on truthiness.
*/
export function fuzzyMatch(query, candidate) {
const needle = String(query ?? '').toLowerCase();
const haystack = String(candidate ?? '').toLowerCase();
if (!needle) {
return {score: 1, positions: []};
}
if (haystack === needle) {
return {score: 10000, positions: [...Array(needle.length).keys()]};
}
const direct = haystack.indexOf(needle);
if (direct !== -1) {
const boundary = direct === 0 || /[\s/._-]/.test(haystack[direct - 1]);
const positions = [...Array(needle.length).keys()].map(index => direct + index);
return {score: 1000 + (boundary ? 200 : 0) - direct - (haystack.length - needle.length) / 10, positions};
}
let score = 0;
let position = 0;
let previous = -2;
const positions = [];
for (const character of needle) {
const found = haystack.indexOf(character, position);
if (found === -1) {
return null;
}
const before = haystack[found - 1];
const boundary = found === 0 || /[\s/._-]/.test(before) || (before && before !== before.toUpperCase() && candidate[found] === candidate[found].toUpperCase());
score += 10;
if (boundary) {
score += 15;
}
if (found === previous + 1) {
score += 20;
}
score -= Math.min(20, (found - position) * 2);
positions.push(found);
previous = found;
position = found + 1;
}
return {score: Math.max(1, score), positions};
}
/** Filter and sort `items` by `fuzzyMatch` on `pick(item)`, best first. */
export function fuzzyFilter(query, items, pick = item => String(item), limit = 50) {
const scored = [];
for (const item of items) {
const match = fuzzyMatch(query, pick(item));
if (match) {
scored.push({item, score: match.score, positions: match.positions});
}
}
scored.sort((a, b) => b.score - a.score || String(pick(a.item)).localeCompare(String(pick(b.item))));
return scored.slice(0, limit);
}
/**
* Keyboard shortcuts, as data.
*
* A binding is `"cmd+shift+p"`: modifiers in any order, then one key. They
* follow VS Code where the app has the same command.
*/
export const KEYBINDINGS = {
quickOpen: 'cmd+p',
commandPalette: 'cmd+shift+p',
goToSymbol: 'cmd+shift+o',
goToLine: 'ctrl+g',
save: 'cmd+s',
saveAll: 'cmd+alt+s',
closeTab: 'cmd+w',
closeAllTabs: 'cmd+shift+w',
reopenClosedTab: 'cmd+shift+t',
nextTab: 'ctrl+tab',
previousTab: 'ctrl+shift+tab',
nextTabAlt: 'cmd+shift+]',
previousTabAlt: 'cmd+shift+[',
splitEditor: 'cmd+\\',
focusFirstGroup: 'cmd+1',
focusSecondGroup: 'cmd+2',
toggleSidebar: 'cmd+b',
toggleTerminal: 'cmd+j',
newTerminal: 'ctrl+shift+`',
showFiles: 'cmd+shift+e',
showSearch: 'cmd+shift+f',
showGit: 'ctrl+shift+g',
showProblems: 'cmd+shift+m',
openFolder: 'cmd+o',
newFile: 'cmd+n',
formatDocument: 'shift+alt+f',
dismiss: 'escape',
};
/** Command ids as the command palette labels them. */
export const COMMAND_LABELS = {
quickOpen: 'Go to file',
commandPalette: 'Command palette',
goToSymbol: 'Go to symbol in file',
goToLine: 'Go to line',
save: 'Save',
saveAll: 'Save all',
closeTab: 'Close tab',
closeAllTabs: 'Close all tabs',
reopenClosedTab: 'Reopen closed tab',
nextTab: 'Next tab',
previousTab: 'Previous tab',
nextTabAlt: 'Next tab (alternate)',
previousTabAlt: 'Previous tab (alternate)',
splitEditor: 'Split editor',
focusFirstGroup: 'Focus first editor group',
focusSecondGroup: 'Focus second editor group',
toggleSidebar: 'Toggle sidebar',
toggleTerminal: 'Toggle terminal',
newTerminal: 'New terminal',
showFiles: 'Show files',
showSearch: 'Search in files',
showGit: 'Show source control',
showProblems: 'Show problems',
openFolder: 'Open folder',
newFile: 'New file',
formatDocument: 'Format document',
dismiss: 'Dismiss / close overlay',
};
const MODIFIERS = {
cmd: 'meta',
command: 'meta',
meta: 'meta',
ctrl: 'ctrl',
control: 'ctrl',
alt: 'alt',
option: 'alt',
opt: 'alt',
shift: 'shift',
};
/** Names the OS spells one way and the web another. */
const KEY_ALIASES = {
esc: 'escape',
return: 'enter',
space: ' ',
spacebar: ' ',
up: 'arrowup',
down: 'arrowdown',
left: 'arrowleft',
right: 'arrowright',
plus: '=',
backtick: '`',
del: 'delete',
};
export function parseKeybinding(text) {
if (!text || typeof text !== 'string') {
return null;
}
const parts = text
.trim()
.toLowerCase()
.split('+')
.map(part => part.trim());
// `cmd++` is cmd and the plus key: an empty part means a literal `+`.
// `cmd++` splits to ['cmd', '', ''] — the run of empties is the one key.
const normalised = [];
for (let index = 0; index < parts.length; index++) {
if (parts[index] === '') {
if (index > 0 && parts[index - 1] !== '') {
normalised.push('+');
}
} else {
normalised.push(parts[index]);
}
}
const binding = {key: null, meta: false, ctrl: false, alt: false, shift: false};
for (const part of normalised) {
const modifier = MODIFIERS[part];
if (modifier) {
binding[modifier] = true;
continue;
}
if (binding.key !== null) {
return null;
}
binding.key = KEY_ALIASES[part] ?? part;
}
return binding.key === null ? null : binding;
}
/** A key event from any of the three places one can arrive from. */
export function normaliseKeyEvent(event) {
const source = event?.nativeEvent ?? event ?? {};
const rawKey = source.key ?? '';
let key = String(rawKey).toLowerCase();
// Shift+2 arrives as `@` on the web and as `2` from AppKit; a binding is
// written against the unshifted key, so both have to land on it.
if (source.code && /^(Key|Digit)/.test(source.code) && key.length === 1) {
key = source.code.replace(/^Key|^Digit/, '').toLowerCase();
}
key = KEY_ALIASES[key] ?? key;
return {
key,
meta: Boolean(source.metaKey),
ctrl: Boolean(source.ctrlKey),
alt: Boolean(source.altKey),
shift: Boolean(source.shiftKey),
};
}
export function matchesBinding(binding, event) {
if (!binding) {
return false;
}
const pressed = normaliseKeyEvent(event);
return (
pressed.key === binding.key &&
pressed.meta === binding.meta &&
pressed.ctrl === binding.ctrl &&
pressed.alt === binding.alt &&
pressed.shift === binding.shift
);
}
/**
* The user's bindings over the defaults, parsed once. Returned as a list so
* lookup is a scan — there are a few dozen, and it runs once per key.
*/
export function buildKeymap() {
return Object.entries(KEYBINDINGS)
.map(([command, text]) => ({command, text, binding: parseKeybinding(text)}))
.filter(entry => entry.binding);
}
export function commandForEvent(keymap, event) {
const entry = keymap.find(candidate => matchesBinding(candidate.binding, event));
return entry ? entry.command : null;
}
/**
* The bindings as the pieces of the app that see key events need them: the
* `keyDownEvents` list that tells AppKit which keys are ours, and the same
* list for the two WebViews to intercept and hand back.
*/
export function keyDownEventsFor(keymap) {
return keymap.map(({binding}) => ({
key: displayKey(binding.key),
metaKey: binding.meta,
ctrlKey: binding.ctrl,
altKey: binding.alt,
shiftKey: binding.shift,
}));
}
/** The key as a DOM `KeyboardEvent.key` spells it. */
function displayKey(key) {
const names = {
escape: 'Escape',
enter: 'Enter',
tab: 'Tab',
arrowup: 'ArrowUp',
arrowdown: 'ArrowDown',
arrowleft: 'ArrowLeft',
arrowright: 'ArrowRight',
backspace: 'Backspace',
delete: 'Delete',
};
return names[key] ?? key;
}
/** `cmd+shift+p` -> `⌘⇧P`, for menus and the palette. */
export function formatKeybinding(text) {
const binding = parseKeybinding(text);
if (!binding) {
return '';
}
const glyphs = {
escape: '⎋', enter: '↩', tab: '⇥', arrowup: '↑', arrowdown: '↓', arrowleft: '←', arrowright: '→',
backspace: '⌫', delete: '⌦', ' ': '␣',
};
return [
binding.ctrl ? '⌃' : '',
binding.alt ? '⌥' : '',
binding.shift ? '⇧' : '',
binding.meta ? '⌘' : '',
glyphs[binding.key] ?? binding.key.toUpperCase(),
].join('');
}
/**
* Line diff for the gutter: which lines of the buffer are new, changed or
* sit where committed lines used to be. Myers' O(ND) algorithm over lines,
* after trimming the common prefix and suffix — a file with one edit is a
* one-line problem however long it is.
*
* Both functions are pure and run on the React Native side; the result is
* pushed to Monaco as decorations.
*/
export function splitLines(text) {
const lines = String(text ?? '').split('\n');
// A trailing newline is the end of the last line, not an extra empty one.
if (lines.length > 1 && lines[lines.length - 1] === '') {
lines.pop();
}
return lines;
}
/**
* Returns hunks of `{originalStart, originalLength, modifiedStart, modifiedLength}`
* with zero-based starts. An insertion has originalLength 0; a deletion has
* modifiedLength 0.
*/
export function lineDiff(originalText, modifiedText) {
const a = splitLines(originalText);
const b = splitLines(modifiedText);
let start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) {
start++;
}
let endA = a.length;
let endB = b.length;
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
endA--;
endB--;
}
const middleA = a.slice(start, endA);
const middleB = b.slice(start, endB);
if (middleA.length === 0 && middleB.length === 0) {
return [];
}
if (middleA.length === 0 || middleB.length === 0) {
return [{originalStart: start, originalLength: middleA.length, modifiedStart: start, modifiedLength: middleB.length}];
}
const edits = myers(middleA, middleB);
return coalesce(edits, start);
}
/** Myers' shortest edit script; returns a list of {type: 'equal'|'delete'|'insert', a, b}. */
function myers(a, b) {
const n = a.length;
const m = b.length;
const max = n + m;
const offset = max;
const trace = [];
let v = new Array(2 * max + 2).fill(0);
let finished = false;
for (let d = 0; d <= max && !finished; d++) {
const next = v.slice();
for (let k = -d; k <= d && !finished; k += 2) {
let x;
if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) {
x = v[offset + k + 1];
} else {
x = v[offset + k - 1] + 1;
}
let y = x - k;
while (x < n && y < m && a[x] === b[y]) {
x++;
y++;
}
next[offset + k] = x;
if (x >= n && y >= m) {
finished = true;
}
}
trace.push(next);
v = next;
}
// Walk the trace back to recover the path.
const edits = [];
let x = n;
let y = m;
for (let d = trace.length - 1; d >= 0; d--) {
const vd = trace[d];
const k = x - y;
let previousK;
if (k === -d || (k !== d && vd[offset + k - 1] < vd[offset + k + 1])) {
previousK = k + 1;
} else {
previousK = k - 1;
}
const previousX = d === 0 ? 0 : trace[d - 1][offset + previousK];
const previousY = previousX - previousK;
while (x > previousX && y > previousY) {
edits.push({type: 'equal', a: x - 1, b: y - 1});
x--;
y--;
}
if (d > 0) {
if (x === previousX) {
edits.push({type: 'insert', a: x, b: y - 1});
y--;
} else {
edits.push({type: 'delete', a: x - 1, b: y});
x--;
}
}
}
edits.reverse();
return edits;
}
/** Runs of inserts and deletes become hunks; equal lines close them. */
function coalesce(edits, base) {
const hunks = [];
let current = null;
for (const edit of edits) {
if (edit.type === 'equal') {
current = null;
continue;
}
if (!current) {
current = {
originalStart: base + (edit.type === 'delete' ? edit.a : edit.a),
originalLength: 0,
modifiedStart: base + (edit.type === 'insert' ? edit.b : edit.b),
modifiedLength: 0,
};
hunks.push(current);
}
if (edit.type === 'delete') {
current.originalLength++;
} else {
current.modifiedLength++;
}
}
return hunks;
}
/**
* Hunks as the gutter draws them, one-based and inclusive: `added` and
* `modified` cover buffer lines; `deleted` marks the line after which
* committed lines are missing (line 0 means before the first).
*/
export function gutterChanges(hunks) {
return hunks.map(hunk => {
if (hunk.originalLength === 0) {
return {kind: 'added', startLine: hunk.modifiedStart + 1, endLine: hunk.modifiedStart + hunk.modifiedLength};
}
if (hunk.modifiedLength === 0) {
return {kind: 'deleted', startLine: hunk.modifiedStart, endLine: hunk.modifiedStart};
}
return {kind: 'modified', startLine: hunk.modifiedStart + 1, endLine: hunk.modifiedStart + hunk.modifiedLength};
});
}
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import React, {useState} from 'react';
import {glass, useThemedStyles, withAlpha} from '../theme';
/** Fixed, theme-independent colours for git state — the same ones every editor uses. */
export const GIT_COLORS = {
added: '#3fb950',
untracked: '#3fb950',
modified: '#d29922',
deleted: '#f85149',
renamed: '#58a6ff',
conflict: '#f85149',
};
export const SEVERITY_COLORS = {
error: '#f85149',
warning: '#d29922',
info: '#58a6ff',
hint: '#8b949e',
};
/**
* A small text button. `tone` picks the material: `accent` for the primary
* action in a view, `subtle` for everything else, `danger` for deletes.
*/
export function SmallButton({title, onPress, tone = 'subtle', disabled = false, style, testID}) {
const styles = useThemedStyles(createStyles);
return (
<Pressable
onPress={onPress}
disabled={disabled}
testID={testID}
accessibilityRole="button"
style={({pressed}) => [
styles.button,
tone === 'accent' && styles.buttonAccent,
tone === 'danger' && styles.buttonDanger,
disabled && styles.disabled,
pressed && styles.pressed,
style,
]}>
<Text style={[styles.buttonText, tone === 'accent' && styles.buttonTextAccent, tone === 'danger' && styles.buttonTextDanger]}>
{title}
</Text>
</Pressable>
);
}
/** A glyph-only button for row actions; `label` is the accessibility name and tooltip. */
export function IconButton({glyph, label, onPress, tone, size = 14, style, testID}) {
const styles = useThemedStyles(createStyles);
const [hovered, setHovered] = useState(false);
return (
<Pressable
onPress={event => {
event?.stopPropagation?.();
onPress?.(event);
}}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
accessibilityLabel={label}
accessibilityRole="button"
tooltip={label}
testID={testID}
hitSlop={4}
style={[styles.iconButton, hovered && styles.iconButtonHovered, style]}>
<Text style={[styles.iconGlyph, {fontSize: size}, tone === 'danger' && styles.buttonTextDanger]}>{glyph}</Text>
</Pressable>
);
}
export function SectionTitle({title, right, style}) {
const styles = useThemedStyles(createStyles);
return (
<View style={[styles.sectionTitle, style]}>
<Text style={styles.sectionTitleText}>{title}</Text>
{right}
</View>
);
}
export function Field({value, onChangeText, placeholder, onSubmitEditing, onKeyPress, autoFocus, inputRef, style, monospace, testID, secureTextEntry}) {
const styles = useThemedStyles(createStyles);
return (
<TextInput
ref={inputRef}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={styles.placeholder.color}
onSubmitEditing={onSubmitEditing}
onKeyPress={onKeyPress}
autoFocus={autoFocus}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
enableFocusRing={false}
blurOnSubmit={false}
secureTextEntry={secureTextEntry}
testID={testID}
style={[styles.field, monospace && styles.mono, style]}
/>
);
}
export function Toggle({label, value, onChange, description}) {
const styles = useThemedStyles(createStyles);
return (
<Pressable onPress={() => onChange(!value)} style={styles.toggleRow} accessibilityRole="switch" accessibilityState={{checked: value}}>
<View style={[styles.toggle, value && styles.toggleOn]}>
<View style={[styles.knob, value && styles.knobOn]} />
</View>
<View style={{flex: 1}}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? <Text style={styles.toggleDescription}>{description}</Text> : null}
</View>
</Pressable>
);
}
/** A count in a rounded pill — for tabs and the panel switcher. */
export function Badge({count, color}) {
const styles = useThemedStyles(createStyles);
if (!count) {
return null;
}
return (
<View style={[styles.badge, color && {backgroundColor: color}]}>
<Text style={styles.badgeText}>{count > 99 ? '99+' : count}</Text>
</View>
);
}
export function EmptyState({title, hint}) {
const styles = useThemedStyles(createStyles);
return (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>{title}</Text>
{hint ? <Text style={styles.emptyHint}>{hint}</Text> : null}
</View>
);
}
const createStyles = colors => StyleSheet.create({
button: {
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 8,
...glass(colors, {variant: 'subtle'}),
},
buttonAccent: {
...glass(colors, {variant: 'accent'}),
},
buttonDanger: {
borderColor: GIT_COLORS.deleted,
},
buttonText: {
color: colors.onSurface,
fontSize: 12,
fontWeight: '500',
},
buttonTextAccent: {
color: colors.onPrimary,
},
buttonTextDanger: {
color: GIT_COLORS.deleted,
},
disabled: {opacity: 0.4},
pressed: {opacity: 0.7},
iconButton: {
width: 22,
height: 22,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
},
iconButtonHovered: {
backgroundColor: withAlpha(colors.onSurface, 0.1),
},
iconGlyph: {
color: colors.onSurface,
},
sectionTitle: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 8,
marginBottom: 6,
},
sectionTitleText: {
color: colors.onSurfaceVariant,
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.6,
textTransform: 'uppercase',
},
field: {
paddingVertical: 6,
paddingHorizontal: 10,
borderRadius: 8,
color: colors.onSurface,
fontSize: 13,
...glass(colors, {variant: 'subtle'}),
},
mono: {
fontFamily: 'Menlo',
},
placeholder: {
color: withAlpha(colors.onSurfaceVariant, 0.7),
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 6,
},
toggle: {
width: 32,
height: 18,
borderRadius: 9,
padding: 2,
backgroundColor: withAlpha(colors.onSurface, 0.2),
},
toggleOn: {
backgroundColor: colors.primary,
},
knob: {
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: '#fff',
},
knobOn: {
marginLeft: 14,
},
toggleLabel: {
color: colors.onSurface,
fontSize: 13,
},
toggleDescription: {
color: colors.onSurfaceVariant,
fontSize: 11,
marginTop: 1,
},
badge: {
minWidth: 16,
height: 16,
paddingHorizontal: 4,
borderRadius: 8,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
badgeText: {
color: '#fff',
fontSize: 10,
fontWeight: '700',
},
empty: {
padding: 16,
alignItems: 'center',
gap: 4,
},
emptyTitle: {
color: colors.onSurfaceVariant,
fontSize: 13,
fontWeight: '600',
textAlign: 'center',
},
emptyHint: {
color: withAlpha(colors.onSurfaceVariant, 0.8),
fontSize: 12,
textAlign: 'center',
},
});
import {useCallback, useEffect, useRef, useState} from 'react';
import {fileSystem} from '../fileSystem';
import {sortFiles} from './sortFiles';
/**
* The tree as data: which folders are open and what each one holds. Folders
* are fetched when first expanded and refetched when the watcher says
* something inside them changed, so the rows always reflect the disk.
*
* Paths are project-relative; '' is the root.
*/
export function useFileTree(projectRoot, {onError} = {}) {
const [children, setChildren] = useState({});
const [expanded, setExpanded] = useState(() => new Set());
const [loading, setLoading] = useState(() => new Set());
const alive = useRef(true);
const root = useRef(projectRoot);
const errorHandler = useRef(onError);
errorHandler.current = onError;
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const load = useCallback(
async path => {
if (!projectRoot) {
return;
}
setLoading(current => new Set(current).add(path));
try {
const response = await fileSystem.listFiles(projectRoot, path);
if (!alive.current || root.current !== projectRoot) {
return;
}
setChildren(current => ({...current, [path]: sortFiles(response.contents ?? [])}));
} catch (error) {
errorHandler.current?.(error);
} finally {
if (alive.current) {
setLoading(current => {
const next = new Set(current);
next.delete(path);
return next;
});
}
}
},
[projectRoot],
);
// A new project starts from nothing: only its root is fetched.
useEffect(() => {
root.current = projectRoot;
setChildren({});
setExpanded(new Set());
if (projectRoot) {
load('');
}
}, [projectRoot, load]);
const toggle = useCallback(
path => {
setExpanded(current => {
const next = new Set(current);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
if (!children[path]) {
load(path);
}
}
return next;
});
},
[children, load],
);
const expand = useCallback(
path => {
setExpanded(current => {
if (current.has(path)) {
return current;
}
const next = new Set(current);
next.add(path);
return next;
});
if (!children[path]) {
load(path);
}
},
[children, load],
);
/** Open every folder on the way to `path`, so it can be shown selected. */
const reveal = useCallback(
path => {
const parts = path.split('/').slice(0, -1);
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
expand(current);
}
},
[expand],
);
/** The watcher's batch: refetch every folder that is showing and had a change. */
const applyChanges = useCallback(
changes => {
const folders = new Set();
for (const change of changes) {
const parent = change.path.includes('/') ? change.path.slice(0, change.path.lastIndexOf('/')) : '';
folders.add(parent);
// A folder that was removed takes its listing with it.
if (change.kind === 'delete') {
setChildren(current => {
if (!(change.path in current)) {
return current;
}
const next = {...current};
delete next[change.path];
return next;
});
}
}
for (const folder of folders) {
if (folder === '' || expanded.has(folder) || children[folder]) {
load(folder);
}
}
},
[expanded, children, load],
);
const refresh = useCallback(() => {
load('');
for (const path of expanded) {
load(path);
}
}, [expanded, load]);
return {children, expanded, loading, toggle, expand, reveal, applyChanges, refresh, load};
}
/**
* The rows the tree renders, flattened in display order: every expanded
* folder's children follow it, indented one level deeper.
*/
export function flattenTree(children, expanded, path = '', depth = 0) {
const rows = [];
for (const entry of children[path] ?? []) {
const isOpen = entry.isDirectory && expanded.has(entry.fullPath);
rows.push({...entry, depth, isOpen});
if (isOpen) {
rows.push(...flattenTree(children, expanded, entry.fullPath, depth + 1));
}
}
return rows;
}
import {useEffect, useRef} from 'react';
import {fileSystem} from '../fileSystem';
const RETRY_MS = [1000, 2000, 5000, 10000];
/**
* File changes in the project, as the server sees them. `onChanges` gets the
* batched list; the socket reconnects on its own for as long as the page is
* mounted, with a backoff so a server that is down is not hammered.
*/
export function useProjectEvents(projectRoot, onChanges, {enabled = true} = {}) {
const handler = useRef(onChanges);
handler.current = onChanges;
useEffect(() => {
if (!projectRoot || !enabled) {
return undefined;
}
let socket = null;
let attempt = 0;
let timer = null;
let closed = false;
const connect = () => {
if (closed) {
return;
}
try {
socket = new WebSocket(fileSystem.eventsUrl(projectRoot));
} catch (error) {
schedule();
return;
}
socket.onopen = () => {
attempt = 0;
};
socket.onmessage = event => {
let message;
try {
message = JSON.parse(event.data);
} catch (error) {
return;
}
if (message.type === 'fs') {
handler.current?.(message.changes);
}
};
socket.onclose = () => {
socket = null;
schedule();
};
socket.onerror = () => {
// onclose follows; the retry is scheduled there.
};
};
const schedule = () => {
if (closed) {
return;
}
const delay = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)];
attempt++;
timer = setTimeout(connect, delay);
};
connect();
return () => {
closed = true;
clearTimeout(timer);
if (socket) {
socket.onclose = null;
socket.close();
}
};
}, [projectRoot, enabled]);
}
import {MONACO_CDN, SHIKI_CDN, SHIKI_MONACO_CDN} from '../monacoTheme';
import {XTERM_CDN, XTERM_FIT_CDN} from './terminalTheme';
import {fileSystem} from '../fileSystem';
/**
* Where the two WebViews load Monaco, xterm and Shiki from.
*
* With the file server up they come off it, from the copies in `node_modules`
* (or the bundle's own, see `stage-sidecar.sh`), so the editor boots with no
* network and no CDN in the loop. Without it the CDNs are the fallback — the
* page can still show its "server is down" state in a working editor.
*/
export const CDN_SOURCES = {
local: false,
base: MONACO_CDN,
monaco: MONACO_CDN,
xterm: XTERM_CDN,
xtermFit: XTERM_FIT_CDN,
shiki: SHIKI_CDN,
shikiMonaco: SHIKI_MONACO_CDN,
importMap: null,
};
export function localSources(importMap) {
return {
local: true,
base: `${fileSystem.vendorUrl('monaco-editor', 'min')}`,
monaco: fileSystem.vendorUrl('monaco-editor', 'min'),
xterm: fileSystem.vendorUrl('@xterm/xterm'),
xtermFit: fileSystem.vendorUrl('@xterm/addon-fit'),
shiki: importMap?.imports?.shiki ?? SHIKI_CDN,
shikiMonaco: importMap?.imports?.['@shikijs/monaco'] ?? SHIKI_MONACO_CDN,
importMap,
};
}
let pending = null;
/**
* One probe per launch. Resolved rather than assumed, because a page that
* bakes the local URLs into its document and then finds the server down has
* nothing to fall back to — the document is built exactly once.
*/
export function resolveVendorSources({force = false} = {}) {
if (pending && !force) {
return pending;
}
pending = fileSystem
.importMap()
.then(importMap => localSources(importMap))
.catch(() => CDN_SOURCES);
return pending;
}
import secrets from './secerts.json';
import secrets from './secerts.json';
const BASE_URL = "http://127.0.0.1:4001";
export const BASE_URL = 'http://127.0.0.1:4001';
/**
* Anything the server refused, with the status and the message it gave. The
* page shows the message in a toast, and checks `status` for the cases it
* handles itself — 409 on a write is "the file changed under you".
*/
export class FileSystemError extends Error {
constructor(status, message, url) {
super(message);
this.status = status;
this.url = url;
}
}
/** The server is not answering at all — as opposed to answering "no". */
export class ServerUnavailableError extends Error {
constructor(cause) {
super('The file server is not running');
this.cause = cause;
}
}
function query(parameters) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(parameters)) {
if (value !== undefined && value !== null) {
search.set(key, String(value));
}
}
const text = search.toString();
return text ? `?${text}` : '';
}
export const fileSystem = {
export const fileSystem = {
get defaultHeaders() {
get defaultHeaders() {
return {
return {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Accept: "application/json",
Accept: 'application/json',
Authorization: `Bearer ${secrets.API_KEY}`,
Authorization: `Bearer ${secrets.API_KEY}`,
};
};
},
},
async request(method, route, parameters = {}, body) {
const url = `${BASE_URL}${route}${query(parameters)}`;
let response;
try {
response = await fetch(url, {
method,
headers: this.defaultHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
throw new ServerUnavailableError(error);
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : await response.text();
if (!response.ok) {
const message = (isJson && data?.error) || `${method} ${route} failed with ${response.status}`;
throw new FileSystemError(response.status, message, url);
}
return data;
},
health() {
return this.request('GET', '/health');
},
listFiles(projectRoot, path) {
listFiles(projectRoot, path) {
return this.makeRequest(projectRoot, "/files", path);
return this.request('GET', '/files', {projectRoot, path});
},
},
readFile(projectRoot, path) {
readFile(projectRoot, path) {
return this.makeRequest(projectRoot, "/file", path);
return this.request('GET', '/file', {projectRoot, path});
},
},
searchFiles(projectRoot, searchTerm, type = 'files') {
const fullUrl = `/search?projectRoot=${encodeURIComponent(projectRoot)}&term=${encodeURIComponent(searchTerm)}&type=${encodeURIComponent(type)}`;
return fetch(BASE_URL + fullUrl, {
method: "GET",
headers: { ...this.defaultHeaders },
}).then(async (response) => {
const isJson = response.headers.get("content-type")?.includes("application/json");
const data = isJson ? await response.json() : await response.text();
return data;
stat(projectRoot, path) {
return this.request('GET', '/stat', {projectRoot, path});
},
/** `expectedMtime` makes the write conditional: a 409 means the disk moved on. */
writeFile(projectRoot, path, contents, expectedMtime) {
return this.request('PUT', '/file', {projectRoot, path}, {contents, expectedMtime});
},
createFile(projectRoot, path, contents = '') {
return this.request('POST', '/file', {projectRoot, path}, {contents});
},
createDirectory(projectRoot, path) {
return this.request('POST', '/directory', {projectRoot, path});
},
deletePath(projectRoot, path) {
return this.request('DELETE', '/file', {projectRoot, path});
},
rename(projectRoot, from, to) {
return this.request('POST', '/rename', {projectRoot, from, to});
},
searchFiles(projectRoot, term, type = 'files', options = {}) {
return this.request('GET', '/search', {
projectRoot,
term,
type,
caseSensitive: options.caseSensitive ? 'true' : undefined,
regex: options.regex ? 'true' : undefined,
wholeWord: options.wholeWord ? 'true' : undefined,
});
});
},
},
replace(projectRoot, term, replacement, options = {}) {
return this.request('POST', '/replace', {projectRoot}, {term, replacement, ...options});
},
getState() {
return this.request('GET', '/state');
},
patchState(patch) {
return this.request('PATCH', '/state', {}, patch);
},
git: {
status: projectRoot => fileSystem.request('GET', '/git/status', {projectRoot}),
show: (projectRoot, path, ref) => fileSystem.request('GET', '/git/show', {projectRoot, path, ref}),
diff: (projectRoot, path, staged) => fileSystem.request('GET', '/git/diff', {projectRoot, path, staged: staged ? 'true' : undefined}),
log: (projectRoot, limit) => fileSystem.request('GET', '/git/log', {projectRoot, limit}),
branches: projectRoot => fileSystem.request('GET', '/git/branches', {projectRoot}),
stage: (projectRoot, paths) => fileSystem.request('POST', '/git/stage', {projectRoot}, paths === 'all' ? {all: true} : {paths}),
unstage: (projectRoot, paths) => fileSystem.request('POST', '/git/unstage', {projectRoot}, paths === 'all' ? {all: true} : {paths}),
discard: (projectRoot, paths) => fileSystem.request('POST', '/git/discard', {projectRoot}, {paths}),
commit: (projectRoot, message, amend = false) => fileSystem.request('POST', '/git/commit', {projectRoot}, {message, amend}),
checkout: (projectRoot, branch, create = false) => fileSystem.request('POST', '/git/checkout', {projectRoot}, {branch, create}),
},
/**
/**
* The terminal panel's socket. Built here so the host lives in one place,
* The terminal panel's socket. Built here so the host lives in one place,
* even though nothing about it is a request: the shell is spawned at the size
* even though nothing about it is a request: the shell is spawned at the size
* the emulator measured, in the project the editor has open.
* the emulator measured, in the project the editor has open.
*/
*/
terminalUrl(projectRoot, size = null) {
terminalUrl(projectRoot, size = null) {
const parameters = [];
if (projectRoot) {
parameters.push(`cwd=${encodeURIComponent(projectRoot)}`);
}
if (size) {
parameters.push(`cols=${size.cols}`, `rows=${size.rows}`);
}
return socketUrl('/terminal', {
cwd: projectRoot,
cols: size?.cols,
rows: size?.rows,
});
},
return `${BASE_URL.replace(/^http/, "ws")}/terminal?${parameters.join("&")}`;
eventsUrl(projectRoot) {
return socketUrl('/events', {projectRoot});
},
},
writeFile(projectRoot, path, contents) {
const fullUrl = `/file?path=${encodeURIComponent(path)}&projectRoot=${encodeURIComponent(projectRoot)}`;
const options = {
method: "PUT",
headers: {
...this.defaultHeaders,
},
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();
});
lspUrl(projectRoot, language) {
return socketUrl('/lsp', {projectRoot, language});
},
},
makeRequest(projectRoot, url, path = null, headers = {}) {
const fullUrl = path ? `${url}?path=${encodeURIComponent(path)}&projectRoot=${encodeURIComponent(projectRoot)}` : `${url}?projectRoot=${encodeURIComponent(projectRoot)}`;
const options = {
method: "GET",
headers: {
...this.defaultHeaders,
...headers,
/** Where the WebViews load their libraries from when the server is up. */
vendorUrl(packageName, rest = '') {
return `${BASE_URL}/vendor/${packageName}${rest ? `/${rest}` : ''}`;
},
},
};
// React Native has no page to resolve relative URLs against, so every
// request is prefixed with the API host.
return fetch(BASE_URL + fullUrl, options)
.then(async (response) => {
if (!response.ok) {
throw new Error(`GET ${fullUrl} failed with ${response.status}`);
}
if (response.headers.get("content-type")?.includes("application/json")) { return response.json(); }
return response.text();
});
importMap() {
return this.request('GET', '/import-map');
},
},
};
};
function socketUrl(route, parameters) {
return `${BASE_URL.replace(/^http/, 'ws')}${route}${query(parameters)}`;
}
minimap: {enabled: false},
minimap: {enabled: false},
scrollBeyondLastLine: false,
scrollBeyondLastLine: false,
fontSize: 14,
fontSize: 14,
tabSize: 2,
insertSpaces: true,
wordWrap: 'off',
lineNumbers: 'on',
renderWhitespace: 'selection',
bracketPairColorization: {enabled: true},
guides: {indentation: true, bracketPairs: false},
smoothScrolling: true,
padding: {top: 12, bottom: 12},
padding: {top: 12, bottom: 12},
stickyScroll: {enabled: false},
stickyScroll: {enabled: false},
};
};
"dependencies": {
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@monaco-editor/react": "^4.7.0",
"@react-native/new-app-screen": "0.81.6",
"@react-native/new-app-screen": "0.81.6",
"@shikijs/monaco": "^4.4.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"monaco-editor": "^0.52.2",
"node-pty": "^1.1.0",
"node-pty": "^1.1.0",
"react": "19.1.4",
"react": "19.1.4",
"react-dom": "^19.1.4",
"react-dom": "^19.1.4",
"react-native-svg": "^15.15.5",
"react-native-svg": "^15.15.5",
"react-native-web": "^0.21.2",
"react-native-web": "^0.21.2",
"react-native-webview": "^14.0.1",
"react-native-webview": "^14.0.1",
"shiki": "^4.4.3",
"ws": "^8.21.3"
"ws": "^8.21.3"
},
},
"devDependencies": {
"devDependencies": {
import {attachTerminal} from './terminal.mjs';
import {ENDPOINT as EVENTS_ENDPOINT, openEvents} from './watcher.mjs';
import {ENDPOINT as LSP_ENDPOINT, languageServers, openLanguageServer} from './lsp.mjs';
import {ENDPOINT as TERMINAL_ENDPOINT, fromLoopback, openShell} from './terminal.mjs';
import {WebSocketServer} from 'ws';
import {createRouter} from './fileserver/router.mjs';
import {createRouter} from './fileserver/router.mjs';
import http from 'node:http';
import http from 'node:http';
const HOST = '127.0.0.1';
const HOST = '127.0.0.1';
const PORT = 4001;
const PORT = Number(process.env.AIO_PORT) || 4001;
const server = http.createServer(createRouter());
const server = http.createServer(createRouter());
// The editor's shell, on the same port: `ws://127.0.0.1:4001/terminal`.
attachTerminal(server);
/**
* Three sockets on the one port: `/terminal` (a shell), `/events` (file
* changes) and `/lsp` (a language server). A WebSocket handshake is not
* subject to CORS, so every one of them is refused unless the `Origin` could
* not have come from a website — see `fromLoopback` in `terminal.mjs`.
*/
const SOCKETS = {
[TERMINAL_ENDPOINT]: openShell,
[EVENTS_ENDPOINT]: openEvents,
[LSP_ENDPOINT]: openLanguageServer,
};
const sockets = new WebSocketServer({noServer: true});
server.on('upgrade', (request, socket, head) => {
const {pathname, searchParams} = new URL(request.url, 'http://localhost');
const open = SOCKETS[pathname];
if (!open) {
socket.destroy();
return;
}
if (!fromLoopback(request.headers.origin)) {
console.error(`Refused ${pathname} from origin: ${request.headers.origin}`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
sockets.handleUpgrade(request, socket, head, connection => {
open(connection, searchParams);
});
});
// End the process when the app is stopped
// End the process when the app is stopped
if (process.env.AIO_EXIT_ON_STDIN_EOF === '1') {
if (process.env.AIO_EXIT_ON_STDIN_EOF === '1') {
process.stdin.on('end', () => process.exit(0));
process.stdin.on('end', () => process.exit(0));
process.stdin.resume();
process.stdin.resume();
}
}
server.on('error', error => {
if (error.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is taken — is another file server running?`);
process.exit(1);
}
throw error;
});
server.listen(PORT, HOST, () => {
server.listen(PORT, HOST, () => {
console.log(`listening on http://${HOST}:${PORT}`);
console.log(`listening on http://${HOST}:${PORT}`);
// Finding language servers means asking the login shell, once per binary.
// Done now, off the request path, so the first health check does not wait
// for it.
setTimeout(() => {
const available = languageServers();
console.log(`language servers: ${Object.keys(available).join(', ') || 'none'}`);
}, 0);
});
});
import {readBody, sendJson} from '../http.mjs';
import {HttpError, readJson, sendJson} from '../http.mjs';
import fs from 'node:fs/promises';
import fs from 'node:fs/promises';
import path from 'node:path';
import path from 'node:path';
import {resolveInProject} from '../projectPaths.mjs';
import {resolveTarget} from '../projectPaths.mjs';
export async function readFile({response, searchParams}) {
export async function readFile({response, searchParams}) {
const target = resolveTarget(searchParams);
const target = resolveTarget(searchParams);
const stats = await stat(target.absolute);
if (stats.isDirectory()) {
throw new HttpError(400, `Not a file: ${target.wantedPath}`);
}
const contents = await fs.readFile(target.absolute, 'utf8');
const contents = await fs.readFile(target.absolute, 'utf8');
sendJson(response, {path: target.wantedPath, contents});
sendJson(response, {
path: target.wantedPath,
contents,
mtime: stats.mtimeMs,
size: stats.size,
});
}
export async function statFile({response, searchParams}) {
const target = resolveTarget(searchParams);
const stats = await stat(target.absolute);
sendJson(response, {
path: target.wantedPath,
exists: true,
isDirectory: stats.isDirectory(),
mtime: stats.mtimeMs,
size: stats.size,
});
}
}
export async function listDirectory({response, searchParams}) {
export async function listDirectory({response, searchParams}) {
export async function writeFile({request, response, searchParams}) {
export async function writeFile({request, response, searchParams}) {
const target = resolveTarget(searchParams);
const target = resolveTarget(searchParams);
const body = await readJson(request);
let contents = JSON.parse(await readBody(request)).contents;
if (typeof body.contents !== 'string') {
throw new HttpError(400, 'contents must be a string');
}
// The client may say which version of the file it edited. A write against
// a file that changed underneath it is refused rather than clobbering the
// other author's work; the client then gets to ask the user.
if (body.expectedMtime != null) {
const current = await stat(target.absolute).catch(() => null);
if (current && Math.abs(current.mtimeMs - body.expectedMtime) > 1) {
throw new HttpError(409, `File changed on disk: ${target.wantedPath}`);
}
}
await fs.writeFile(target.absolute, contents, 'utf8');
await fs.writeFile(target.absolute, body.contents, 'utf8');
sendJson(response, {path: target.wantedPath});
const stats = await stat(target.absolute);
sendJson(response, {path: target.wantedPath, mtime: stats.mtimeMs});
}
}
export async function deleteFile({response, searchParams}) {
export async function deleteFile({response, searchParams}) {
const target = resolveTarget(searchParams);
const target = resolveTarget(searchParams);
if (target.absolute === target.projectRoot) {
throw new HttpError(400, 'Refusing to delete the project root');
}
await fs.unlink(target.absolute);
await stat(target.absolute);
await fs.rm(target.absolute, {recursive: true});
sendJson(response, {path: target.wantedPath});
sendJson(response, {path: target.wantedPath});
}
}
export async function createFile({request, response, searchParams}) {
export async function createFile({request, response, searchParams}) {
const target = resolveTarget(searchParams);
const target = resolveTarget(searchParams);
const body = await readJson(request);
let contents = JSON.parse(await readBody(request)).contents;
if (await exists(target.absolute)) {
throw new HttpError(409, `Already exists: ${target.wantedPath}`);
}
await fs.mkdir(path.dirname(target.absolute), {recursive: true});
await fs.writeFile(target.absolute, contents, 'utf8');
await fs.writeFile(target.absolute, body.contents ?? '', 'utf8');
const stats = await stat(target.absolute);
sendJson(response, {path: target.wantedPath, mtime: stats.mtimeMs});
}
export async function createDirectory({response, searchParams}) {
const target = resolveTarget(searchParams);
if (await exists(target.absolute)) {
throw new HttpError(409, `Already exists: ${target.wantedPath}`);
}
await fs.mkdir(target.absolute, {recursive: true});
sendJson(response, {path: target.wantedPath});
sendJson(response, {path: target.wantedPath});
}
}
/** Rename and move are the same operation: `from` and `to` are project paths. */
export async function renameFile({response, searchParams}) {
const from = resolveTarget(searchParams, 'from');
function resolveTarget(searchParams) {
const to = resolveTarget(searchParams, 'to');
const projectRoot = searchParams.get('projectRoot');
const requestedPath = searchParams.get('path');
if (!searchParams.get('to')) {
throw new HttpError(400, 'to is required');
}
if (from.absolute === from.projectRoot) {
throw new HttpError(400, 'Refusing to move the project root');
}
await stat(from.absolute);
if (await exists(to.absolute)) {
throw new HttpError(409, `Already exists: ${to.wantedPath}`);
}
await fs.mkdir(path.dirname(to.absolute), {recursive: true});
await fs.rename(from.absolute, to.absolute);
sendJson(response, {from: from.wantedPath, to: to.wantedPath});
}
async function stat(absolute) {
try {
return await fs.stat(absolute);
} catch (error) {
if (error.code === 'ENOENT') {
throw new HttpError(404, `Not found: ${absolute}`);
}
const wantedPath = requestedPath || '';
const absolute = resolveInProject(projectRoot, wantedPath);
throw error;
}
}
return {absolute, wantedPath};
async function exists(absolute) {
try {
await fs.lstat(absolute);
return true;
} catch (error) {
return false;
}
}
}
import {HttpError, readJson, sendJson} from '../http.mjs';
import {execFile} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {resolveTarget} from '../projectPaths.mjs';
const MAX_OUTPUT = 20 * 1024 * 1024;
/**
* `git` as a subprocess, in the project. A project that is not a repository is
* a normal state for the editor to be in, so that case is reported rather than
* thrown: the sidebar shows "not a repository" and the tree stays uncoloured.
*/
export function git(cwd, args, {input} = {}) {
return new Promise((resolve, reject) => {
const child = execFile(
'git',
['-c', 'core.quotepath=off', ...args],
{cwd, maxBuffer: MAX_OUTPUT, env: {...process.env, GIT_OPTIONAL_LOCKS: '0'}},
(error, stdout, stderr) => {
if (error) {
error.stderr = stderr;
reject(error);
return;
}
resolve(stdout);
},
);
if (input != null) {
child.stdin.end(input);
}
});
}
async function isRepository(cwd) {
try {
const out = await git(cwd, ['rev-parse', '--is-inside-work-tree']);
return out.trim() === 'true';
} catch (error) {
return false;
}
}
/**
* `git status --porcelain=v1 -z`: two status letters, a space, the path, and
* for a rename a second NUL-separated path (the original) after it.
*/
export function parseStatus(raw) {
const entries = [];
const parts = raw.split('\0');
for (let index = 0; index < parts.length; index++) {
const part = parts[index];
if (!part) {
continue;
}
const index_ = part[0];
const worktree = part[1];
const filePath = part.slice(3);
const entry = {path: filePath, index: index_, worktree};
if (index_ === 'R' || index_ === 'C') {
entry.from = parts[++index];
}
entries.push(entry);
}
return entries;
}
function realpath(target) {
try {
return fs.realpathSync(path.resolve(target));
} catch (error) {
return path.resolve(target);
}
}
export async function status({response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
if (!(await isRepository(projectRoot))) {
sendJson(response, {repository: false, branch: null, changes: []});
return;
}
const [branch, raw, root] = await Promise.all([
git(projectRoot, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => 'HEAD'),
git(projectRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
git(projectRoot, ['rev-parse', '--show-toplevel']),
]);
sendJson(response, {
repository: true,
branch: branch.trim(),
root: root.trim(),
changes: relativeToProject(parseStatus(raw), root.trim(), projectRoot),
});
}
/**
* Porcelain output names paths from the repository root whatever the working
* directory, and the project the editor has open may be a folder inside the
* repository. Everything else in the editor is project-relative, so the
* status is made so too; changes outside the project are not its concern.
*/
export function relativeToProject(changes, repositoryRoot, projectRoot) {
// git reports the real path; the project may have been opened through a
// symlink (macOS's /tmp and /var are ones), so both are resolved the same way.
const repo = realpath(repositoryRoot);
const project = realpath(projectRoot);
if (repo === project) {
return changes;
}
const rebase = filePath => {
const relative = path.relative(project, path.join(repo, filePath));
return relative.startsWith('..') || path.isAbsolute(relative) ? null : relative;
};
const result = [];
for (const change of changes) {
const rebased = rebase(change.path);
if (rebased === null) {
continue;
}
const next = {...change, path: rebased};
if (change.from) {
next.from = rebase(change.from) ?? change.from;
}
result.push(next);
}
return result;
}
/** The committed version of a file, for the gutter and the diff view. */
export async function show({response, searchParams}) {
const {projectRoot, wantedPath} = resolveTarget(searchParams);
const ref = searchParams.get('ref') || 'HEAD';
if (!/^[\w./~^-]+$/.test(ref)) {
throw new HttpError(400, `Bad ref: ${ref}`);
}
try {
const contents = await git(projectRoot, ['show', `${ref}:./${wantedPath}`]);
sendJson(response, {path: wantedPath, ref, contents, exists: true});
} catch (error) {
// A new file has no committed version; that is an empty original, not
// an error.
sendJson(response, {path: wantedPath, ref, contents: '', exists: false});
}
}
export async function diff({response, searchParams}) {
const {projectRoot, wantedPath} = resolveTarget(searchParams);
const staged = searchParams.get('staged') === 'true';
const args = ['diff', '--no-color'];
if (staged) {
args.push('--cached');
}
if (wantedPath) {
args.push('--', wantedPath);
}
sendJson(response, {path: wantedPath, diff: await git(projectRoot, args)});
}
export async function stage({request, response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const {paths, all} = await readJson(request);
if (all) {
await git(projectRoot, ['add', '-A']);
} else {
await git(projectRoot, ['add', '-A', '--', ...requirePaths(paths)]);
}
sendJson(response, {ok: true});
}
export async function unstage({request, response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const {paths, all} = await readJson(request);
if (all) {
await git(projectRoot, ['reset', '-q']);
} else {
await git(projectRoot, ['reset', '-q', '--', ...requirePaths(paths)]);
}
sendJson(response, {ok: true});
}
/** Throw away the working-tree changes of a file: `git checkout -- path`. */
export async function discard({request, response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const {paths} = await readJson(request);
for (const filePath of requirePaths(paths)) {
try {
await git(projectRoot, ['checkout', '--', filePath]);
} catch (error) {
// Untracked: nothing to check out, so it is removed instead.
await git(projectRoot, ['clean', '-f', '--', filePath]);
}
}
sendJson(response, {ok: true});
}
export async function commit({request, response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const {message, amend} = await readJson(request);
if (!amend && (typeof message !== 'string' || !message.trim())) {
throw new HttpError(400, 'A commit message is required');
}
const args = ['commit', '-F', '-'];
if (amend) {
args.push('--amend');
if (!message) {
args.push('--no-edit');
}
}
try {
const out = await git(projectRoot, args, {input: message ?? ''});
sendJson(response, {ok: true, output: out});
} catch (error) {
throw new HttpError(400, (error.stderr || error.message).trim());
}
}
export async function log({response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const limit = Math.min(200, Number(searchParams.get('limit')) || 30);
const raw = await git(projectRoot, [
'log',
`-n${limit}`,
'--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1e',
]).catch(() => '');
const commits = raw
.split('\x1e')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const [hash, short, author, date, subject] = line.split('\x1f');
return {hash, short, author, date, subject};
});
sendJson(response, {commits});
}
export async function branches({response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const raw = await git(projectRoot, ['branch', '--format=%(refname:short)%09%(HEAD)']).catch(() => '');
const list = raw
.split('\n')
.filter(Boolean)
.map(line => {
const [name, head] = line.split('\t');
return {name, current: head === '*'};
});
sendJson(response, {branches: list});
}
export async function checkout({request, response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const {branch, create} = await readJson(request);
if (typeof branch !== 'string' || !/^[\w.][\w./-]*$/.test(branch)) {
throw new HttpError(400, 'A valid branch name is required');
}
try {
await git(projectRoot, create ? ['checkout', '-b', branch] : ['checkout', branch]);
} catch (error) {
throw new HttpError(400, (error.stderr || error.message).trim());
}
sendJson(response, {ok: true});
}
function requirePaths(paths) {
if (!Array.isArray(paths) || paths.length === 0) {
throw new HttpError(400, 'paths is required');
}
for (const filePath of paths) {
if (typeof filePath !== 'string' || filePath.startsWith('-')) {
throw new HttpError(400, `Bad path: ${filePath}`);
}
}
return paths;
}
import {HttpError, readJson, sendJson} from '../http.mjs';
import {searchCode} from '../search/codeSearch.mjs';
import {replaceInFiles, searchCode} from '../search/codeSearch.mjs';
import {resolveTarget} from '../projectPaths.mjs';
import {searchFileNames} from '../search/fileSearch.mjs';
import {searchFileNames} from '../search/fileSearch.mjs';
import {sendJson} from '../http.mjs';
const SEARCH_OPTIONS = new Map([
const SEARCH_OPTIONS = new Map([
['files', {name: 'files', run: searchFileNames}],
['files', {name: 'files', run: searchFileNames}],
['code', {name: 'code', run: searchCode}],
['code', {name: 'code', run: searchCode}],
]);
]);
export async function search({response, searchParams}) {
export async function search({response, searchParams}) {
const projectRoot = searchParams.get('projectRoot');
const {projectRoot} = resolveTarget(searchParams);
const term = searchParams.get('term');
const term = searchParams.get('term') ?? '';
const type = searchParams.get('type') ?? 'files';
const searching = SEARCH_OPTIONS.get(type);
if (!searching) {
throw new HttpError(400, `Unknown search type: ${type}`);
}
if (!term) {
sendJson(response, {results: []});
return;
}
const options = {
caseSensitive: searchParams.get('caseSensitive') === 'true',
regex: searchParams.get('regex') === 'true',
wholeWord: searchParams.get('wholeWord') === 'true',
};
sendJson(response, {results: await searching.run(projectRoot, term, options)});
}
/**
* Find and replace across the project. `paths`, when given, limits the write
* to those files — the sidebar sends the ones the user left ticked.
*/
export async function replace({request, response, searchParams}) {
const {projectRoot} = resolveTarget(searchParams);
const body = await readJson(request);
if (typeof body.term !== 'string' || !body.term) {
throw new HttpError(400, 'term is required');
}
if (typeof body.replacement !== 'string') {
throw new HttpError(400, 'replacement must be a string');
}
const searching = SEARCH_OPTIONS.get(searchParams.get('type'));
const result = await replaceInFiles(projectRoot, body.term, body.replacement, {
caseSensitive: Boolean(body.caseSensitive),
regex: Boolean(body.regex),
wholeWord: Boolean(body.wholeWord),
paths: Array.isArray(body.paths) ? body.paths : null,
});
sendJson(response, {results: await searching.run(projectRoot, term)});
sendJson(response, result);
}
}
import {readJson, sendJson} from '../http.mjs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
/**
* The editor's memory between launches: recent projects and the tabs open in
* each. One JSON file, owned by the client — the server
* stores what it is given and hands it back, nothing more.
*/
export const STATE_DIRECTORY = process.env.AIO_STATE_DIR || path.join(os.homedir(), '.all-in-one');
const STATE_FILE = 'editor-state.json';
export function stateFile() {
return path.join(STATE_DIRECTORY, STATE_FILE);
}
export async function readState() {
try {
return JSON.parse(await fs.readFile(stateFile(), 'utf8'));
} catch (error) {
return {};
}
}
export async function writeState(state) {
await fs.mkdir(STATE_DIRECTORY, {recursive: true});
// Written beside and renamed over, so a crash mid-write leaves the old
// state rather than half a file.
const temporary = `${stateFile()}.${process.pid}.tmp`;
await fs.writeFile(temporary, JSON.stringify(state, null, 2), 'utf8');
await fs.rename(temporary, stateFile());
}
export async function getState({response}) {
sendJson(response, await readState());
}
export async function putState({request, response}) {
const state = await readJson(request);
await writeState(state);
sendJson(response, {ok: true});
}
/** Merge a few keys in rather than replacing the whole file. */
export async function patchState({request, response}) {
const patch = await readJson(request);
const state = {...(await readState()), ...patch};
await writeState(state);
sendJson(response, state);
}
import {HttpError} from '../http.mjs';
import fs from 'node:fs';
import path from 'node:path';
/**
* The editor's browser-side libraries, served off this port so the Code page
* works with no network at all. Only the packages the two WebViews load are
* exposed, each pinned to the directory it is installed in — the server never
* hands out an arbitrary `node_modules` path.
*
* In the bundled app these live beside the server (see `stage-sidecar.sh`);
* in development they are the project's own `node_modules`.
*/
const PACKAGES = [
'monaco-editor',
'@xterm/xterm',
'@xterm/addon-fit',
'shiki',
'@shikijs/core',
'@shikijs/engine-javascript',
'@shikijs/engine-oniguruma',
'@shikijs/langs',
'@shikijs/monaco',
'@shikijs/themes',
'@shikijs/types',
'@shikijs/vscode-textmate',
];
const TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.cjs': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.wasm': 'application/wasm',
'.ttf': 'font/ttf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.svg': 'image/svg+xml',
'.html': 'text/html',
'.map': 'application/json',
};
export const VENDOR_PREFIX = '/vendor/';
/**
* `node_modules` beside the entry point — which is `scripts/fileserver.mjs`
* in development, so one level up, and the bundle's own root in the app — or
* beside the working directory.
*/
export function findNodeModules() {
const entry = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd();
const candidates = [
process.env.AIO_NODE_MODULES,
path.join(entry, 'node_modules'),
path.join(entry, '..', 'node_modules'),
path.join(process.cwd(), 'node_modules'),
].filter(Boolean);
return candidates.find(candidate => fs.existsSync(path.join(candidate, 'monaco-editor'))) ?? candidates[candidates.length - 1];
}
/**
* The import map the Shiki ESM bundle needs: its packages import each other by
* bare name, and a browser resolves those only through a map. Built here so the
* document and the server agree on the URLs.
*/
export function importMap(base) {
const imports = {};
for (const name of PACKAGES) {
const packageJson = readPackage(name);
if (!packageJson) {
continue;
}
const main = packageEntry(packageJson);
if (main) {
imports[name] = `${base}${VENDOR_PREFIX}${name}/${main}`;
}
imports[`${name}/`] = `${base}${VENDOR_PREFIX}${name}/`;
}
return {imports};
}
function readPackage(name) {
try {
return JSON.parse(fs.readFileSync(path.join(findNodeModules(), name, 'package.json'), 'utf8'));
} catch (error) {
return null;
}
}
function packageEntry(packageJson) {
const exports = packageJson.exports?.['.'];
const pick = value => {
if (!value) {
return null;
}
if (typeof value === 'string') {
return value;
}
return pick(value.import) ?? pick(value.default) ?? pick(value.browser);
};
return (pick(exports) ?? packageJson.module ?? packageJson.main ?? 'index.js').replace(/^\.\//, '');
}
/** `@shikijs/langs/tsx` -> `@shikijs/langs/dist/tsx.mjs`, via the package's `exports`. */
function resolveSubpath(name, rest) {
const packageJson = readPackage(name);
const exports = packageJson?.exports;
if (!exports || !rest) {
return rest;
}
const key = `./${rest}`;
if (exports[key]) {
return pickEntry(exports[key]);
}
for (const [pattern, target] of Object.entries(exports)) {
if (!pattern.includes('*')) {
continue;
}
const [prefix, suffix] = pattern.split('*');
if (key.startsWith(prefix) && key.endsWith(suffix)) {
const wildcard = key.slice(prefix.length, key.length - suffix.length);
const resolved = pickEntry(target);
return resolved ? resolved.replace('*', wildcard) : rest;
}
}
return rest;
}
function pickEntry(value) {
if (!value) {
return null;
}
if (typeof value === 'string') {
return value.replace(/^\.\//, '');
}
return pickEntry(value.import) ?? pickEntry(value.default) ?? pickEntry(value.browser);
}
export function serveVendor({request, response, pathname}) {
const relative = decodeURIComponent(pathname.slice(VENDOR_PREFIX.length));
const name = PACKAGES.find(candidate => relative === candidate || relative.startsWith(`${candidate}/`));
if (!name) {
throw new HttpError(404, `Not a vendored package: ${relative}`);
}
const root = path.join(findNodeModules(), name);
// The bare package name is its entry point, as the import map says.
const rest = relative.slice(name.length + 1) || packageEntry(readPackage(name) ?? {});
let absolute = path.resolve(root, rest);
if (!absolute.startsWith(root + path.sep) && absolute !== root) {
throw new HttpError(400, 'Path escapes the package');
}
if (!fs.existsSync(absolute) || fs.statSync(absolute).isDirectory()) {
const viaExports = resolveSubpath(name, rest);
absolute = path.resolve(root, viaExports ?? '');
if (!fs.existsSync(absolute) || fs.statSync(absolute).isDirectory()) {
throw new HttpError(404, `Not found: ${relative}`);
}
}
const type = TYPES[path.extname(absolute).toLowerCase()] ?? 'application/octet-stream';
response.writeHead(200, {
'Content-Type': type,
'Cache-Control': 'public, max-age=31536000, immutable',
'Access-Control-Allow-Origin': '*',
});
if (request.method === 'HEAD') {
response.end();
return;
}
fs.createReadStream(absolute).pipe(response);
}
export function sendJson(response, payload) {
export function sendJson(response, payload, status = 200) {
response.writeHead(200, {'Content-Type': 'application/json'});
response.writeHead(status, {'Content-Type': 'application/json'});
response.end(JSON.stringify(payload));
response.end(JSON.stringify(payload));
}
}
export function sendError(response, status, message) {
sendJson(response, {error: message}, status);
}
/**
* A request the handler could not honour, carrying the status it should be
* answered with. Anything else that is thrown is a 500.
*/
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
export function readBody(request) {
export function readBody(request) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
let body = '';
let body = '';
request.on('error', reject);
request.on('error', reject);
});
});
}
}
export async function readJson(request) {
const body = await readBody(request);
if (!body) {
return {};
}
try {
return JSON.parse(body);
} catch (error) {
throw new HttpError(400, `Body is not JSON: ${error.message}`);
}
}
import {HttpError} from './http.mjs';
import path from 'node:path';
import path from 'node:path';
// Make sure that the relative path stays within the project root
// Make sure that the relative path stays within the project root
export function resolveInProject(projectRoot, relativePath) {
export function resolveInProject(projectRoot, relativePath) {
if (!projectRoot) {
return null;
}
const root = path.resolve(projectRoot);
const root = path.resolve(projectRoot);
const absolute = path.resolve(root, relativePath);
const absolute = path.resolve(root, relativePath ?? '');
return contains(root, absolute) ? absolute : null;
return contains(root, absolute) ? absolute : null;
}
}
/**
* The handlers' view of a request: the project it is about, the path inside
* it, and where that is on disk. A path that escapes the project, or a request
* with no project at all, is refused here so no handler has to remember to.
*/
export function resolveTarget(searchParams, key = 'path') {
const projectRoot = searchParams.get('projectRoot');
const wantedPath = searchParams.get(key) || '';
if (!projectRoot) {
throw new HttpError(400, 'projectRoot is required');
}
const absolute = resolveInProject(projectRoot, wantedPath);
if (!absolute) {
throw new HttpError(400, `Path escapes the project: ${wantedPath}`);
}
return {projectRoot: path.resolve(projectRoot), absolute, wantedPath};
}
// Check if the absolute path is contained within the root directory
// Check if the absolute path is contained within the root directory
function contains(root, absolute) {
function contains(root, absolute) {
const relative = path.relative(root, absolute);
const relative = path.relative(root, absolute);
import { createFile, deleteFile, listDirectory, readFile, writeFile } from './handlers/files.mjs';
import {HttpError, sendError, sendJson} from './http.mjs';
import {VENDOR_PREFIX, importMap, serveVendor} from './handlers/vendor.mjs';
import * as git from './handlers/git.mjs';
import {
createDirectory,
createFile,
deleteFile,
listDirectory,
readFile,
renameFile,
statFile,
writeFile,
} from './handlers/files.mjs';
import {getState, patchState, putState} from './handlers/state.mjs';
import {replace, search} from './handlers/search.mjs';
import {languageServers} from '../lsp.mjs';
import { search } from './handlers/search.mjs';
const routes = [
export const routes = [
{method: 'GET', path: '/health', handler: health},
{ method: 'GET', path: '/file', handler: readFile },
{method: 'GET', path: '/file', handler: readFile},
{ method: 'PUT', path: '/file', handler: writeFile },
{method: 'PUT', path: '/file', handler: writeFile},
{ method: 'POST', path: '/file', handler: createFile },
{method: 'POST', path: '/file', handler: createFile},
{ method: 'DELETE', path: '/file', handler: deleteFile },
{method: 'DELETE', path: '/file', handler: deleteFile},
{method: 'GET', path: '/stat', handler: statFile},
{method: 'POST', path: '/directory', handler: createDirectory},
{method: 'POST', path: '/rename', handler: renameFile},
{ method: 'GET', path: '/files', handler: listDirectory },
{method: 'GET', path: '/files', handler: listDirectory},
{ method: 'GET', path: '/search', handler: search },
{method: 'GET', path: '/search', handler: search},
{method: 'POST', path: '/replace', handler: replace},
{method: 'GET', path: '/state', handler: getState},
{method: 'PUT', path: '/state', handler: putState},
{method: 'PATCH', path: '/state', handler: patchState},
{method: 'GET', path: '/git/status', handler: git.status},
{method: 'GET', path: '/git/show', handler: git.show},
{method: 'GET', path: '/git/diff', handler: git.diff},
{method: 'GET', path: '/git/log', handler: git.log},
{method: 'GET', path: '/git/branches', handler: git.branches},
{method: 'POST', path: '/git/stage', handler: git.stage},
{method: 'POST', path: '/git/unstage', handler: git.unstage},
{method: 'POST', path: '/git/discard', handler: git.discard},
{method: 'POST', path: '/git/commit', handler: git.commit},
{method: 'POST', path: '/git/checkout', handler: git.checkout},
{method: 'GET', path: '/import-map', handler: serveImportMap},
];
];
function health({response}) {
sendJson(response, {
ok: true,
pid: process.pid,
version: process.version,
languageServers: languageServers(),
});
}
function serveImportMap({request, response}) {
const base = `http://${request.headers.host ?? '127.0.0.1:4001'}`;
sendJson(response, importMap(base));
}
export function createRouter() {
export function createRouter() {
return async function handle(request, response) {
return async function handle(request, response) {
const {pathname, searchParams} = new URL(request.url, 'http://localhost');
const {pathname, searchParams} = new URL(request.url, 'http://localhost');
const route = routes.find(
candidate => candidate.method === request.method && candidate.path === pathname,
);
try {
if (pathname.startsWith(VENDOR_PREFIX)) {
serveVendor({request, response, pathname});
return;
}
const route = routes.find(
candidate => candidate.method === request.method && candidate.path === pathname,
);
if (!route) {
throw new HttpError(404, `No route for ${request.method} ${pathname}`);
}
await route.handler({request, response, searchParams});
await route.handler({request, response, searchParams, pathname});
} catch (error) {
if (response.headersSent) {
response.end();
return;
}
if (error instanceof HttpError) {
sendError(response, error.status, error.message);
return;
}
if (error?.code === 'ENOENT') {
sendError(response, 404, error.message);
return;
}
if (error?.code === 'EISDIR' || error?.code === 'ENOTDIR' || error?.code === 'EEXIST') {
sendError(response, 400, error.message);
return;
}
console.error(`${request.method} ${pathname} failed:`, error);
sendError(response, 500, error?.message ?? 'Internal error');
}
};
};
}
}
import {FoldersToIgnore} from './ignore.mjs';
import {FoldersToIgnore} from './ignore.mjs';
import fs from 'node:fs/promises';
import fs from 'node:fs/promises';
import path from 'node:path';
import path from 'node:path';
import {resolveInProject} from '../projectPaths.mjs';
// Non readable files
// Non readable files
const ExtensionsToIgnore = new Set([
const ExtensionsToIgnore = new Set([
'.db', '.sqlite', '.sqlite3', '.lock', '.map',
'.db', '.sqlite', '.sqlite3', '.lock', '.map',
]);
]);
const MAX_CODE_RESULTS = 200;
const MAX_CODE_RESULTS = 500;
const MAX_MATCHES_PER_FILE = 20;
const MAX_MATCHES_PER_FILE = 50;
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const MAX_PREVIEW_LENGTH = 400;
const MAX_PREVIEW_LENGTH = 400;
/**
* One RegExp for both searching and replacing, so the two agree on what a
* match is. A plain term is escaped; `wholeWord` wraps it in boundaries.
*/
export function buildMatcher(term, {caseSensitive = false, regex = false, wholeWord = false} = {}) {
let source = regex ? term : term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (wholeWord) {
source = `\\b(?:${source})\\b`;
}
return new RegExp(source, caseSensitive ? 'g' : 'gi');
}
export async function searchCode(projectRoot, searchTerm) {
export async function searchCode(projectRoot, searchTerm, options = {}) {
const root = path.resolve(projectRoot);
const root = path.resolve(projectRoot);
const results = [];
const results = [];
const matcher = buildMatcher(searchTerm, options);
await walk(root, root, async (absolute, relative) => {
if (results.length >= MAX_CODE_RESULTS) {
return false;
}
const contents = await readText(absolute);
await searchDirectory(root, searchTerm.toLowerCase(), root, results);
if (contents == null) {
return true;
}
const lines = contents.split('\n');
let matchesInFile = 0;
for (let index = 0; index < lines.length; index++) {
if (matchesInFile >= MAX_MATCHES_PER_FILE || results.length >= MAX_CODE_RESULTS) {
break;
}
const line = lines[index].replace(/\r$/, '');
matcher.lastIndex = 0;
const match = matcher.exec(line);
if (!match) {
continue;
}
results.push({
path: relative,
line: index + 1,
column: match.index + 1,
length: match[0].length,
text: line.length > MAX_PREVIEW_LENGTH ? line.slice(0, MAX_PREVIEW_LENGTH) : line,
});
matchesInFile++;
}
return true;
});
return results;
return results;
}
}
async function searchDirectory(directory, searchTermLower, projectRoot, results) {
let entries;
export async function replaceInFiles(projectRoot, term, replacement, options = {}) {
const root = path.resolve(projectRoot);
const matcher = buildMatcher(term, options);
const changed = [];
let replacements = 0;
try {
entries = await fs.readdir(directory, {withFileTypes: true});
} catch (error) {
console.error(`Skipping unreadable directory: ${directory}; error: ${error}`);
return;
}
const targets = options.paths
? options.paths
.map(relative => ({absolute: resolveInProject(root, relative), relative}))
.filter(target => target.absolute)
: null;
for (const entry of entries) {
if (results.length >= MAX_CODE_RESULTS) {
return;
const visit = async (absolute, relative) => {
const contents = await readText(absolute);
if (contents == null) {
return true;
}
}
const fullPath = path.join(directory, entry.name);
let count = 0;
const next = contents.replace(matcher, (...args) => {
count++;
// A regex replacement may use $1 etc.; a plain one is literal.
return options.regex ? substitute(replacement, args) : replacement;
});
if (entry.isDirectory()) {
if (!FoldersToIgnore.includes(entry.name)) {
await searchDirectory(fullPath, searchTermLower, projectRoot, results);
}
continue;
if (count > 0) {
await fs.writeFile(absolute, next, 'utf8');
changed.push({path: relative, replacements: count});
replacements += count;
}
}
// Exclude symlinks
if (!entry.isFile()) {
continue;
return true;
};
if (targets) {
for (const target of targets) {
await visit(target.absolute, target.relative);
}
}
} else {
await walk(root, root, visit);
}
if (ExtensionsToIgnore.has(path.extname(entry.name).toLowerCase())) {
continue;
return {changed, replacements};
}
/** `String.replace` with a function loses `$1` expansion; this puts it back. */
function substitute(template, args) {
const groups = args.slice(0, -2);
return template.replace(/\$(\d+|&)/g, (_, token) => {
if (token === '&') {
return groups[0];
}
}
await searchFile(fullPath, searchTermLower, projectRoot, results);
}
return groups[Number(token)] ?? '';
});
}
}
async function searchFile(absolutePath, searchTermLower, projectRoot, results) {
let contents;
async function readText(absolute) {
if (ExtensionsToIgnore.has(path.extname(absolute).toLowerCase())) {
return null;
}
const stats = await fs.stat(absolutePath);
const stats = await fs.stat(absolute);
if (stats.size > MAX_FILE_BYTES) {
if (stats.size > MAX_FILE_BYTES) {
return;
return null;
}
}
contents = await fs.readFile(absolutePath, 'utf8');
const contents = await fs.readFile(absolute, 'utf8');
if (contents.includes('\u0000')) {
if (contents.includes('\u0000')) {
return;
return null;
}
}
const relative = path.relative(projectRoot, absolutePath);
const lines = contents.split('\n');
let matchesInFile = 0;
return contents;
}
for (let index = 0; index < lines.length; index++) {
if (matchesInFile >= MAX_MATCHES_PER_FILE || results.length >= MAX_CODE_RESULTS) {
return;
}
/** Depth-first over the readable files; `visit` returns false to stop early. */
async function walk(directory, root, visit) {
let entries;
const line = lines[index].replace(/\r$/, '');
const column = line.toLowerCase().indexOf(searchTermLower);
try {
entries = await fs.readdir(directory, {withFileTypes: true});
} catch (error) {
console.error(`Skipping unreadable directory: ${directory}; error: ${error}`);
return true;
}
if (column === -1) {
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!FoldersToIgnore.includes(entry.name)) {
const keepGoing = await walk(fullPath, root, visit);
if (!keepGoing) {
return false;
}
}
continue;
continue;
}
}
results.push({
path: relative,
line: index + 1,
column: column + 1,
text: line.length > MAX_PREVIEW_LENGTH ? line.slice(0, MAX_PREVIEW_LENGTH) : line,
});
matchesInFile++;
// Exclude symlinks
if (!entry.isFile()) {
continue;
}
const keepGoing = await visit(fullPath, path.relative(root, fullPath));
if (!keepGoing) {
return false;
}
}
}
return true;
}
}
import fs from 'node:fs/promises';
import fs from 'node:fs/promises';
import path from 'node:path';
import path from 'node:path';
const MAX_RESULTS = 25;
const MAX_RESULTS = 50;
/**
* Fuzzy in the way editors are: every character of the term has to appear in
* the path in order, and a match that lands on the file name, or on word
* starts, ranks above one scattered through directory names.
*/
export function fuzzyScore(term, candidate) {
const needle = term.toLowerCase();
const haystack = candidate.toLowerCase();
if (!needle) {
return 1;
}
// The cheap case first: a substring match, best when it is the whole name.
const name = haystack.slice(haystack.lastIndexOf('/') + 1);
if (name === needle) {
return 1000;
}
if (name.startsWith(needle)) {
return 800;
}
if (name.includes(needle)) {
return 600 - (name.length - needle.length);
}
if (haystack.includes(needle)) {
return 400 - (haystack.length - needle.length) / 4;
}
let score = 0;
let position = 0;
for (const character of needle) {
const found = haystack.indexOf(character, position);
if (found === -1) {
return 0;
}
const previous = haystack[found - 1];
const boundary = found === 0 || previous === '/' || previous === '.' || previous === '_' || previous === '-';
score += boundary ? 10 : 1;
score -= (found - position) / 10;
position = found + 1;
}
return Math.max(1, score);
}
export async function searchFileNames(projectRoot, searchTerm) {
export async function searchFileNames(projectRoot, searchTerm) {
const root = path.resolve(projectRoot);
const root = path.resolve(projectRoot);
const matches = [];
const matches = [];
await searchForFiles(root, searchTerm.toLowerCase(), matches);
await collect(root, root, searchTerm, matches);
matches.forEach(match => {
match.path = path.relative(root, match.path);
});
matches.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
return matches;
return matches.slice(0, MAX_RESULTS).map(match => ({path: match.path}));
}
}
async function searchForFiles(directory, searchTermLower, matches) {
const entries = await fs.readdir(directory, {withFileTypes: true});
async function collect(directory, root, term, matches) {
let entries;
for (const entry of entries) {
if (matches.length >= MAX_RESULTS) {
try {
entries = await fs.readdir(directory, {withFileTypes: true});
} catch (error) {
return;
return;
}
}
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (entry.isDirectory()) {
if (!FoldersToIgnore.includes(entry.name)) {
if (!FoldersToIgnore.includes(entry.name)) {
await searchForFiles(fullPath, searchTermLower, matches);
await collect(fullPath, root, term, matches);
}
}
continue;
continue;
}
}
if (entry.name.toLowerCase().includes(searchTermLower)) {
matches.push({path: fullPath});
const relative = path.relative(root, fullPath);
const score = fuzzyScore(term, relative);
if (score > 0) {
matches.push({path: relative, score});
}
}
}
}
}
}
import {execFileSync, spawn} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
/**
* A language server, over a WebSocket, per language per project.
*
* Monaco ships intelligence for TypeScript, JavaScript, CSS, HTML and JSON and
* nothing else. For everything else the editor speaks LSP to whichever server
* is installed on this machine: the socket carries the JSON-RPC messages as
* they are, and this end frames them onto the server's stdin and unframes its
* stdout. No server installed for a language is a normal state — the client is
* told so and the file opens without hover or completion, as before.
*/
export const ENDPOINT = '/lsp';
/**
* Monaco language id -> the command that speaks LSP over stdio for it. The
* first one whose binary is on PATH wins, so installing any of them is enough.
*/
export const SERVERS = {
typescript: [['typescript-language-server', '--stdio'], ['vtsls', '--stdio']],
javascript: [['typescript-language-server', '--stdio'], ['vtsls', '--stdio']],
ruby: [['ruby-lsp'], ['solargraph', 'stdio']],
erb: [['ruby-lsp']],
python: [['pyright-langserver', '--stdio'], ['pylsp'], ['ruff', 'server']],
go: [['gopls']],
rust: [['rust-analyzer']],
swift: [['sourcekit-lsp']],
c: [['clangd']],
cpp: [['clangd']],
objective_c: [['clangd']],
php: [['intelephense', '--stdio'], ['phpactor', 'language-server']],
css: [['vscode-css-language-server', '--stdio']],
scss: [['vscode-css-language-server', '--stdio']],
less: [['vscode-css-language-server', '--stdio']],
html: [['vscode-html-language-server', '--stdio']],
json: [['vscode-json-language-server', '--stdio']],
yaml: [['yaml-language-server', '--stdio']],
dockerfile: [['docker-langserver', '--stdio']],
shell: [['bash-language-server', 'start']],
lua: [['lua-language-server']],
kotlin: [['kotlin-language-server']],
java: [['jdtls']],
elixir: [['elixir-ls']],
dart: [['dart', 'language-server', '--protocol=lsp']],
markdown: [['marksman', 'server']],
};
const found = new Map();
/**
* Resolved through the user's login shell, like the terminal is: language
* servers are installed by version managers whose shims live in a PATH this
* process may not have inherited.
*/
export function which(binary) {
if (found.has(binary)) {
return found.get(binary);
}
let resolved = null;
try {
const shell = process.env.SHELL || '/bin/zsh';
resolved = execFileSync(shell, ['-lic', `command -v ${binary}`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
}).trim() || null;
} catch (error) {
resolved = null;
}
found.set(binary, resolved);
return resolved;
}
export function serverFor(language) {
for (const [binary, ...args] of SERVERS[language] ?? []) {
const resolved = which(binary);
if (resolved) {
return {command: resolved, args, binary};
}
}
return null;
}
/** Every language with a server installed — the health endpoint reports it. */
export function languageServers() {
const available = {};
for (const language of Object.keys(SERVERS)) {
const server = serverFor(language);
if (server) {
available[language] = server.binary;
}
}
return available;
}
/**
* LSP over stdio is `Content-Length: N\r\n\r\n<N bytes of JSON>`. Bytes arrive
* in whatever chunks the pipe feels like, so the parser is a small state
* machine over a buffer.
*/
export function createFrameParser(onMessage) {
let buffer = Buffer.alloc(0);
return chunk => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const header = buffer.slice(0, headerEnd).toString('ascii');
const match = /Content-Length:\s*(\d+)/i.exec(header);
if (!match) {
// Garbage before a header — drop it and look for the next one.
buffer = buffer.slice(headerEnd + 4);
continue;
}
const length = Number(match[1]);
const start = headerEnd + 4;
if (buffer.length < start + length) {
return;
}
const body = buffer.slice(start, start + length).toString('utf8');
buffer = buffer.slice(start + length);
onMessage(body);
}
};
}
export function frame(json) {
const body = Buffer.from(json, 'utf8');
return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]);
}
export function openLanguageServer(connection, searchParams) {
const language = searchParams.get('language');
const projectRoot = searchParams.get('projectRoot');
const server = serverFor(language);
const send = message => {
if (connection.readyState === connection.OPEN) {
connection.send(JSON.stringify(message));
}
};
if (!server) {
send({type: 'unavailable', language, candidates: (SERVERS[language] ?? []).map(([binary]) => binary)});
connection.close();
return;
}
const cwd = projectRoot && fs.existsSync(projectRoot) ? path.resolve(projectRoot) : process.cwd();
const child = spawn(server.command, server.args, {
cwd,
env: {...process.env, NODE_OPTIONS: undefined},
stdio: ['pipe', 'pipe', 'pipe'],
});
child.on('error', error => {
send({type: 'error', message: `${server.binary}: ${error.message}`});
connection.close();
});
child.stdout.on('data', createFrameParser(body => {
send({type: 'message', body});
}));
child.stderr.on('data', data => {
send({type: 'log', text: data.toString()});
});
child.on('exit', (code, signal) => {
send({type: 'exit', code, signal});
connection.close();
});
send({type: 'ready', language, server: server.binary, cwd});
connection.on('message', raw => {
let message;
try {
message = JSON.parse(raw.toString());
} catch (error) {
return;
}
if (message.type === 'message' && typeof message.body === 'string' && !child.killed) {
child.stdin.write(frame(message.body));
}
});
connection.on('close', () => {
try {
child.kill();
} catch (error) {
// Already gone.
}
});
}
cp "$src/pty.node" "$src/spawn-helper" "$DEST/node_modules/node-pty/prebuilds/darwin-$arch/"
cp "$src/pty.node" "$src/spawn-helper" "$DEST/node_modules/node-pty/prebuilds/darwin-$arch/"
done
done
# The editor's browser-side libraries, served by the file server at
# `/vendor/<package>` so the Code page needs no CDN (see `handlers/vendor.mjs`).
# Only what the two WebViews load: Monaco's minified build, xterm and its fit
# addon, and the Shiki packages behind the syntax highlighting.
for pkg in monaco-editor @xterm/xterm @xterm/addon-fit shiki \
@shikijs/core @shikijs/engine-javascript @shikijs/engine-oniguruma \
@shikijs/langs @shikijs/monaco @shikijs/themes @shikijs/types @shikijs/vscode-textmate; do
if [ ! -d "node_modules/$pkg" ]; then
echo "error: node_modules/$pkg is missing — run npm install" >&2
exit 1
fi
mkdir -p "$DEST/node_modules/$(dirname "$pkg")"
cp -R "node_modules/$pkg" "$DEST/node_modules/$pkg"
done
# Monaco ships an unminified `dev` build and source maps beside `min`; neither
# is loaded, and together they are most of the package.
rm -rf "$DEST/node_modules/monaco-editor/dev" "$DEST/node_modules/monaco-editor/esm" "$DEST/node_modules/monaco-editor/min-maps"
find "$DEST/node_modules/shiki" "$DEST/node_modules/@shikijs" -name '*.d.mts' -delete
find "$DEST/node_modules/shiki" "$DEST/node_modules/@shikijs" -name '*.d.ts' -delete
chmod +x "$DEST/bin/node"
chmod +x "$DEST/bin/node"
find "$DEST/node_modules/node-pty/prebuilds" -name spawn-helper -exec chmod +x {} \;
find "$DEST/node_modules/node-pty/prebuilds" -name spawn-helper -exec chmod +x {} \;
import {WebSocketServer} from 'ws';
import fs from 'node:fs';
import fs from 'node:fs';
import os from 'node:os';
import os from 'node:os';
import path from 'node:path';
import path from 'node:path';
* server. A pty is a stream in both directions and the browser's `fetch` is
* server. A pty is a stream in both directions and the browser's `fetch` is
* not, hence the socket: one per panel, one shell behind each.
* not, hence the socket: one per panel, one shell behind each.
*/
*/
const ENDPOINT = '/terminal';
export const ENDPOINT = '/terminal';
/** Until the page has measured itself and told us what it can actually fit. */
/** Until the page has measured itself and told us what it can actually fit. */
const DEFAULT_SIZE = {cols: 80, rows: 24};
const DEFAULT_SIZE = {cols: 80, rows: 24};
*/
*/
const LOOPBACK_ORIGINS = ['127.0.0.1', 'localhost', '::1'];
const LOOPBACK_ORIGINS = ['127.0.0.1', 'localhost', '::1'];
export function attachTerminal(server) {
// `noServer`, so the origin is checked before the handshake is answered
// rather than after a socket is already live.
const sockets = new WebSocketServer({noServer: true});
server.on('upgrade', (request, socket, head) => {
const {pathname, searchParams} = new URL(request.url, 'http://localhost');
if (pathname !== ENDPOINT) {
socket.destroy();
return;
}
if (!fromLoopback(request.headers.origin)) {
console.error(`Refused terminal from origin: ${request.headers.origin}`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
sockets.handleUpgrade(request, socket, head, connection => {
openShell(connection, searchParams);
});
});
}
/**
* Called by the shared upgrade dispatcher in `fileserver.mjs` once the origin
* has been checked. Exported so every socket endpoint shares one check.
*/
export {openShell};
function fromLoopback(origin) {
export function fromLoopback(origin) {
if (!origin) {
if (!origin) {
return true;
return true;
}
}
import fs from 'node:fs';
import path from 'node:path';
/**
* File change notifications, over a WebSocket, per project.
*
* The tree and the buffers go stale the moment anything but the editor touches
* the project — the terminal, `git checkout`, another editor. macOS gives us a
* recursive `fs.watch`, so one watcher covers the project; events are batched
* for a beat so a `git checkout` that touches hundreds of files arrives as one
* message rather than hundreds.
*/
export const ENDPOINT = '/events';
const DEBOUNCE_MS = 120;
/** Folders whose churn nobody wants to hear about. */
const IGNORED = new Set(['node_modules', '.git', 'Pods', 'build', 'dist', 'DerivedData', '.DS_Store']);
export function isIgnored(relativePath) {
return relativePath
.split(/[\\/]/)
.some(segment => IGNORED.has(segment));
}
/**
* The repository's own bookkeeping is otherwise ignored, but a commit, a
* checkout or a stage happens there and nowhere else — so these few files are
* reported, as a `git` change, and the client refreshes its status.
*/
export function isGitBookkeeping(relativePath) {
return /^\.git\/(index|HEAD|ORIG_HEAD|refs\/|packed-refs)/.test(relativePath.replace(/\\/g, '/'));
}
export function watchProject(projectRoot, onChanges) {
const root = path.resolve(projectRoot);
let pending = new Map();
let timer = null;
const flush = () => {
timer = null;
const changes = [...pending.values()];
pending = new Map();
if (changes.length) {
onChanges(changes);
}
};
let watcher;
try {
watcher = fs.watch(root, {recursive: true}, (eventType, filename) => {
if (!filename) {
return;
}
const relative = String(filename);
if (isGitBookkeeping(relative)) {
pending.set('.git', {path: '.git', kind: 'git', exists: true});
if (!timer) {
timer = setTimeout(flush, DEBOUNCE_MS);
}
return;
}
if (isIgnored(relative)) {
return;
}
// `rename` covers create, delete and move; a stat tells them apart.
let kind = eventType === 'change' ? 'change' : 'rename';
let exists = true;
try {
fs.statSync(path.join(root, relative));
} catch (error) {
exists = false;
}
if (kind === 'rename') {
kind = exists ? 'create' : 'delete';
}
pending.set(relative, {path: relative, kind, exists});
if (!timer) {
timer = setTimeout(flush, DEBOUNCE_MS);
}
});
} catch (error) {
console.error(`Cannot watch ${root}: ${error}`);
return () => {};
}
watcher.on('error', error => console.error(`Watcher error for ${root}: ${error}`));
return () => {
clearTimeout(timer);
watcher.close();
};
}
/**
* One watcher per project, shared by however many panels are listening. It is
* closed when the last listener leaves.
*/
const shared = new Map();
export function subscribe(projectRoot, listener) {
const root = path.resolve(projectRoot);
let entry = shared.get(root);
if (!entry) {
entry = {listeners: new Set(), close: null};
entry.close = watchProject(root, changes => {
for (const callback of entry.listeners) {
callback(changes);
}
});
shared.set(root, entry);
}
entry.listeners.add(listener);
return () => {
entry.listeners.delete(listener);
if (entry.listeners.size === 0) {
entry.close();
shared.delete(root);
}
};
}
export function openEvents(connection, searchParams) {
const projectRoot = searchParams.get('projectRoot');
if (!projectRoot || !fs.existsSync(projectRoot)) {
connection.send(JSON.stringify({type: 'error', message: 'projectRoot is not a directory'}));
connection.close();
return;
}
const unsubscribe = subscribe(projectRoot, changes => {
if (connection.readyState === connection.OPEN) {
connection.send(JSON.stringify({type: 'fs', changes}));
}
});
connection.send(JSON.stringify({type: 'ready', projectRoot}));
connection.on('close', unsubscribe);
}