mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 07:25:13 -05:00
refactor(files): Monaco editor, single-stat listing, kebab actions, hardening
Address review feedback and UX issues on the file browser. Editor - Replace the TipTap editor with Monaco, reusing the self-hosted assets. The editor is kept mounted (wire:ignore) and preloaded on page init, and receives content + language via the load-file-editor event, applying the language with setModelLanguage so highlighting is correct without recreating the instance. - Keep the Monaco instance on the DOM node, not in Alpine reactive state, so its object graph is never proxied (that hung and crashed the tab). - Drop @tiptap/*, lowlight and highlight.js; remove the TipTap CSS and JS. - Broaden guessLanguage to Monaco ids and common extension-less/dotfiles. Listing / read (perf) - List a directory in one stat call instead of a per-entry loop (embedding a real tab, since stat -c does not expand \t), and surface owner/group columns. - Fold the read size-cap, binary probe and base64 into one SSH round trip. Actions - Collapse the per-row Edit/Download/Rename/Delete buttons into a 3-dots menu. - Fix actions silently failing on names with apostrophes: the entry name now lives in the row's Alpine scope instead of passing @js() through a component attribute (which double-encodes the quotes). Hardening - Lock client-controllable state (container/type/resource) with #[Locked]. - Re-check guard() in open/goTo/refresh; sanitize upload filenames; clean up the download temp file and report errors; route >96KiB writes through docker cp. Tests updated and extended (32 passing). Frontend rebuild (npm run build) is still needed only to drop TipTap from the bundle; the editor works without it.
This commit is contained in:
@@ -10,6 +10,8 @@ class FileEntry
|
||||
public int $size,
|
||||
public int $mtime,
|
||||
public string $perms = '',
|
||||
public string $owner = '',
|
||||
public string $group = '',
|
||||
) {}
|
||||
|
||||
public function isDir(): bool
|
||||
|
||||
@@ -187,8 +187,8 @@ class FileBrowser extends Component
|
||||
$this->editorLanguage = $this->guessLanguage($name);
|
||||
$this->editingPath = $path;
|
||||
$this->editorOpen = true;
|
||||
// Push the content into the TipTap editor (reliable across the
|
||||
// teleported, wire:ignore'd modal).
|
||||
// Push content + language into the mounted Monaco editor. It lives
|
||||
// behind wire:ignore, so a dispatched event is how it receives them.
|
||||
$this->dispatch('load-file-editor', content: $this->editorContent, language: $this->editorLanguage);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->dispatch('error', "Can't edit this file - it's binary or larger than 5 MB. Download it instead.");
|
||||
@@ -274,25 +274,41 @@ class FileBrowser extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a filename to a highlight.js language id for the editor.
|
||||
* Map a filename to a Monaco language id for the editor. Falls back to
|
||||
* well-known whole-file names so extension-less files still highlight.
|
||||
*/
|
||||
protected function guessLanguage(string $name): string
|
||||
{
|
||||
$map = [
|
||||
$extMap = [
|
||||
'js' => 'javascript', 'mjs' => 'javascript', 'cjs' => 'javascript', 'ts' => 'typescript',
|
||||
'json' => 'json', 'php' => 'php', 'py' => 'python', 'rb' => 'ruby', 'go' => 'go',
|
||||
'rs' => 'rust', 'java' => 'java', 'sh' => 'bash', 'bash' => 'bash', 'zsh' => 'bash',
|
||||
'rs' => 'rust', 'java' => 'java', 'sh' => 'shell', 'bash' => 'shell', 'zsh' => 'shell',
|
||||
'yml' => 'yaml', 'yaml' => 'yaml', 'toml' => 'ini', 'ini' => 'ini', 'env' => 'ini',
|
||||
'conf' => 'ini', 'md' => 'markdown', 'markdown' => 'markdown', 'html' => 'xml',
|
||||
'htm' => 'xml', 'xml' => 'xml', 'svg' => 'xml', 'css' => 'css', 'scss' => 'scss',
|
||||
'sql' => 'sql', 'dockerfile' => 'dockerfile', 'properties' => 'properties',
|
||||
'conf' => 'ini', 'cnf' => 'ini', 'md' => 'markdown', 'markdown' => 'markdown', 'html' => 'html',
|
||||
'htm' => 'html', 'xml' => 'xml', 'svg' => 'xml', 'css' => 'css', 'scss' => 'scss',
|
||||
'sql' => 'sql', 'dockerfile' => 'dockerfile',
|
||||
];
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if ($ext === '' && strtolower($name) === 'dockerfile') {
|
||||
return 'dockerfile';
|
||||
|
||||
$base = strtolower(basename($name));
|
||||
|
||||
// Whole-name matches for common extension-less/dotfiles.
|
||||
$nameMap = [
|
||||
'dockerfile' => 'dockerfile', 'containerfile' => 'dockerfile',
|
||||
'.gitignore' => 'ini', '.dockerignore' => 'ini', '.editorconfig' => 'ini',
|
||||
'.npmrc' => 'ini', '.gitconfig' => 'ini',
|
||||
'.bashrc' => 'shell', '.bash_profile' => 'shell', '.bash_aliases' => 'shell',
|
||||
'.profile' => 'shell', '.zshrc' => 'shell', '.zprofile' => 'shell',
|
||||
];
|
||||
if (isset($nameMap[$base])) {
|
||||
return $nameMap[$base];
|
||||
}
|
||||
if ($base === '.env' || str_starts_with($base, '.env.')) {
|
||||
return 'ini';
|
||||
}
|
||||
|
||||
return $map[$ext] ?? '';
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
|
||||
return $extMap[$ext] ?? '';
|
||||
}
|
||||
|
||||
protected function childPath(string $name): string
|
||||
@@ -332,6 +348,8 @@ class FileBrowser extends Component
|
||||
'size' => $e->size,
|
||||
'mtime' => $e->mtime,
|
||||
'perms' => $e->perms,
|
||||
'owner' => $e->owner,
|
||||
'group' => $e->group,
|
||||
], $entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,20 +25,18 @@ class ContainerFilesystemService
|
||||
{
|
||||
$escapedPath = $this->escapePath($path, 'list path');
|
||||
|
||||
// Iterate `* .*`, skip . and .., guard literal globs, emit
|
||||
// type<TAB>size<TAB>mtime<TAB>perms<TAB>name. Portable across
|
||||
// busybox/coreutils (%a = octal permission bits).
|
||||
// Emit the whole directory in ONE stat call instead of a per-entry loop
|
||||
// (three stat forks each). %F is the human file type ("directory",
|
||||
// "symbolic link", "regular file"), mapped to a token by parseListing.
|
||||
// Fields: type<TAB>size<TAB>mtime<TAB>perms<TAB>owner<TAB>group<TAB>name.
|
||||
// A real tab is embedded in the format because stat's -c does NOT expand
|
||||
// a backslash \t escape (unlike printf) - it would emit the literal text.
|
||||
// `* .*` covers hidden files; parseListing drops the . and .. rows.
|
||||
// Names containing a tab/newline are skipped by parseListing (v1 scope).
|
||||
$tab = "\t";
|
||||
$format = '%F'.$tab.'%s'.$tab.'%Y'.$tab.'%a'.$tab.'%U'.$tab.'%G'.$tab.'%n';
|
||||
$inner = 'cd '.$escapedPath.' 2>/dev/null || exit 0; '
|
||||
.'for e in * .*; do '
|
||||
.'case "$e" in .|..) continue;; esac; '
|
||||
.'[ -e "$e" ] || [ -L "$e" ] || continue; '
|
||||
.'if [ -L "$e" ]; then t=symlink; elif [ -d "$e" ]; then t=dir; else t=file; fi; '
|
||||
.'s=$(stat -c %s "$e" 2>/dev/null || echo 0); '
|
||||
.'m=$(stat -c %Y "$e" 2>/dev/null || echo 0); '
|
||||
.'p=$(stat -c %a "$e" 2>/dev/null || echo ""); '
|
||||
.'printf "%s\t%s\t%s\t%s\t%s\n" "$t" "$s" "$m" "$p" "$e"; '
|
||||
.'done';
|
||||
.'stat -c "'.$format.'" -- * .* 2>/dev/null';
|
||||
|
||||
return $this->dockerExecShell($inner);
|
||||
}
|
||||
@@ -53,52 +51,42 @@ class ContainerFilesystemService
|
||||
return $this->parseListing($raw);
|
||||
}
|
||||
|
||||
public function isEditable(string $path): bool
|
||||
{
|
||||
$escaped = $this->escapePath($path, 'read path');
|
||||
|
||||
$size = (int) trim((string) instant_remote_process(
|
||||
[$this->dockerExecShell("stat -c %s {$escaped} 2>/dev/null || echo 0")],
|
||||
$this->server,
|
||||
throwError: false,
|
||||
));
|
||||
if ($size > LocalFileVolume::MAX_CONTENT_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// An empty file has no matching line, so grep -qI would report it as
|
||||
// binary. Treat size 0 as editable text.
|
||||
if ($size === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// grep -qI exits non-zero for binary; echo text on success, binary otherwise.
|
||||
$kind = trim((string) instant_remote_process(
|
||||
[$this->dockerExecShell("grep -qI . {$escaped} && echo text || echo binary")],
|
||||
$this->server,
|
||||
throwError: false,
|
||||
));
|
||||
|
||||
return $kind === 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an editable text file in a single SSH round trip.
|
||||
*
|
||||
* The remote command folds the size cap, binary probe and base64 read into
|
||||
* one docker exec: it prints TOOBIG or BINARY for a rejected file, otherwise
|
||||
* an "OK\n" header followed by the base64 payload (empty for a 0-byte file,
|
||||
* which is valid editable text). base64 keeps exact bytes safe from the
|
||||
* trailing-whitespace trim in instant_remote_process.
|
||||
*
|
||||
* @throws \RuntimeException when the file is binary or larger than the cap
|
||||
*/
|
||||
public function read(string $path): string
|
||||
{
|
||||
if (! $this->isEditable($path)) {
|
||||
throw new \RuntimeException('File is not editable (binary or too large).');
|
||||
}
|
||||
|
||||
$escaped = $this->escapePath($path, 'read path');
|
||||
$max = LocalFileVolume::MAX_CONTENT_SIZE;
|
||||
|
||||
// base64 the content so instant_remote_process's trim() cannot corrupt
|
||||
// exact bytes (e.g. trailing newlines).
|
||||
$encoded = (string) instant_remote_process(
|
||||
[$this->dockerExecShell("base64 {$escaped}")],
|
||||
$cmd = 'sz=$(stat -c %s '.$escaped.' 2>/dev/null || echo 0); '
|
||||
.'if [ "$sz" -gt '.$max.' ]; then echo TOOBIG; exit 0; fi; '
|
||||
.'if [ -s '.$escaped.' ] && ! grep -qI . '.$escaped.'; then echo BINARY; exit 0; fi; '
|
||||
.'echo OK; base64 '.$escaped.' 2>/dev/null';
|
||||
|
||||
$raw = (string) instant_remote_process(
|
||||
[$this->dockerExecShell($cmd)],
|
||||
$this->server,
|
||||
throwError: false,
|
||||
);
|
||||
|
||||
return (string) base64_decode(trim($encoded), true);
|
||||
$newline = strpos($raw, "\n");
|
||||
$status = $newline === false ? trim($raw) : substr($raw, 0, $newline);
|
||||
if ($status === 'TOOBIG' || $status === 'BINARY') {
|
||||
throw new \RuntimeException('File is not editable (binary or too large).');
|
||||
}
|
||||
|
||||
$payload = $newline === false ? '' : substr($raw, $newline + 1);
|
||||
|
||||
return (string) base64_decode(trim($payload), true);
|
||||
}
|
||||
|
||||
public function buildWriteCommand(string $path, string $content): string
|
||||
@@ -237,9 +225,14 @@ class ContainerFilesystemService
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$parts = explode("\t", $line, 5);
|
||||
// Accept both the 5-field (with perms) and legacy 4-field formats.
|
||||
if (count($parts) === 5) {
|
||||
$parts = explode("\t", $line, 7);
|
||||
$owner = '';
|
||||
$group = '';
|
||||
// Accept the current 7-field format (with owner/group) and the
|
||||
// legacy 5-field (perms) and 4-field formats.
|
||||
if (count($parts) === 7) {
|
||||
[$type, $size, $mtime, $perms, $owner, $group, $name] = $parts;
|
||||
} elseif (count($parts) === 5) {
|
||||
[$type, $size, $mtime, $perms, $name] = $parts;
|
||||
} elseif (count($parts) === 4) {
|
||||
[$type, $size, $mtime, $name] = $parts;
|
||||
@@ -247,12 +240,41 @@ class ContainerFilesystemService
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
$entries[] = new FileEntry($name, $type, (int) $size, (int) $mtime, trim($perms));
|
||||
// stat lists the directory itself and its parent; skip them.
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
$entries[] = new FileEntry(
|
||||
$name,
|
||||
$this->normalizeType($type),
|
||||
(int) $size,
|
||||
(int) $mtime,
|
||||
trim($perms),
|
||||
trim($owner),
|
||||
trim($group),
|
||||
);
|
||||
}
|
||||
|
||||
return FileEntry::sort($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw type field to a file/dir/symlink token. Accepts both the
|
||||
* already-tokenized legacy value and stat's human %F string.
|
||||
*/
|
||||
protected function normalizeType(string $raw): string
|
||||
{
|
||||
$t = strtolower(trim($raw));
|
||||
if ($t === 'dir' || str_contains($t, 'directory')) {
|
||||
return 'dir';
|
||||
}
|
||||
if ($t === 'symlink' || str_contains($t, 'symbolic link')) {
|
||||
return 'symlink';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
}
|
||||
|
||||
public function isDirectory(string $path): bool
|
||||
{
|
||||
$escaped = $this->escapePath($path, 'stat path');
|
||||
|
||||
Generated
+66
-330
@@ -8,15 +8,8 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tiptap/core": "^3.30.2",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.30.2",
|
||||
"@tiptap/extension-document": "^3.30.2",
|
||||
"@tiptap/extension-text": "^3.30.2",
|
||||
"@tiptap/pm": "^3.30.2",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"playwright": "^1.58.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
@@ -612,6 +605,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
@@ -672,111 +731,6 @@
|
||||
"tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.2.tgz",
|
||||
"integrity": "sha512-QbZC/s1OOqcoUdkhIY16TjR/gCtR0qAk9e4bJwUqOJqZuv5ozqCL5hzWm22jjTPp6c6Ei2tPd6t30VwfIKW4lQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.30.2.tgz",
|
||||
"integrity": "sha512-9otGKaQZmePHrLXFtCtz+BYDn5z4sSumTkUqQIQHz0gVxwPoTi7g51RedwxvViTb/zu2XV5ROXYLHIxKxypMPg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block-lowlight": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.30.2.tgz",
|
||||
"integrity": "sha512-xmC1MOAMl7QmcayV0H/SLxpJ84o4SPwzAcJDhtgUWFdWyf8aO5rnNtUj/2HnvLXlM1nqZPMjTBOmSK7qgPwSZw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/extension-code-block": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2",
|
||||
"highlight.js": "^11",
|
||||
"lowlight": "^2 || ^3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.30.2.tgz",
|
||||
"integrity": "sha512-+xIv67V+/2L1uvz98FAT5W7kWEfHwfNV3MD7b4UsKPU0lhcCWuVOXy0JB8yYmdNExqpI7xT9g3MWzREoBvBQSg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.30.2.tgz",
|
||||
"integrity": "sha512-n/iZnirgRmXet6f97kolAnP3j8DsgLSiTbz/KLWc8eBYiFmkjRzkuisOm5xuGdfGIxwpB4x3tlSF4ef4DLnbRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.2.tgz",
|
||||
"integrity": "sha512-BJN8tUx4ppFN3R3cV/FJfrJbJkvo1lj4uciq+nwpjwzdRvFzqIuglWf+HLcJ6CwlYpLOHp7ArgkBg4Q5e60Gog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.11",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.41.9"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
|
||||
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
@@ -784,12 +738,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
@@ -827,15 +775,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -846,19 +785,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.24.5",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||
@@ -912,15 +838,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
|
||||
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -1231,30 +1148,6 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"highlight.js": "~11.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight/node_modules/highlight.js": {
|
||||
"version": "11.11.2",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
|
||||
"integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1293,12 +1186,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1405,145 +1292,6 @@
|
||||
"react": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
|
||||
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.11",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
|
||||
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz",
|
||||
"integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -1588,12 +1336,6 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2069,12 +1811,6 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,8 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tiptap/core": "^3.30.2",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.30.2",
|
||||
"@tiptap/extension-document": "^3.30.2",
|
||||
"@tiptap/extension-text": "^3.30.2",
|
||||
"@tiptap/pm": "^3.30.2",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"playwright": "^1.58.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
}
|
||||
|
||||
+15
-167
@@ -2259,7 +2259,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
header grid and each row grid resolve identical tracks and stay aligned
|
||||
(an `auto` column would size to its own content per row and drift). */
|
||||
.file-table-grid {
|
||||
grid-template-columns: 1.75rem minmax(10rem, 1fr) 5rem 5.5rem 11rem 10rem;
|
||||
grid-template-columns: 1.75rem minmax(10rem, 1fr) 5rem 6rem 6rem 5rem 11rem 10rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -2272,14 +2272,26 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Hide perms, size and modified columns on small screens */
|
||||
/* Hide perms, owner, group, size and modified columns on small screens */
|
||||
.data-table-row.file-table-grid > :nth-child(3),
|
||||
.data-table-row.file-table-grid > :nth-child(4),
|
||||
.data-table-row.file-table-grid > :nth-child(5) {
|
||||
.data-table-row.file-table-grid > :nth-child(5),
|
||||
.data-table-row.file-table-grid > :nth-child(6),
|
||||
.data-table-row.file-table-grid > :nth-child(7) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* File browser Monaco editor container: fixed viewport-relative height with a
|
||||
rounded, hairline-framed surface that matches the rest of the settings UI. */
|
||||
.file-editor-monaco {
|
||||
height: 60vh;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 1px var(--coollabs-line);
|
||||
}
|
||||
|
||||
/* File browser layout: standard settings workspace grid whose sidebar column
|
||||
collapses smoothly when the nav is toggled (desktop only). Below xl the
|
||||
sidebar stacks as the standard horizontal grid, same as other resource pages. */
|
||||
@@ -2311,170 +2323,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- File browser TipTap code editor (IDE look) --- */
|
||||
.file-ide {
|
||||
--file-line-height: 20px;
|
||||
--file-pad-y: 12px;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: var(--coollabs-recessed);
|
||||
box-shadow: inset 0 0 0 1px var(--coollabs-line);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: var(--file-line-height);
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
/* Line-number gutter: pinned left, scrolls vertically with the code. */
|
||||
.file-ide-gutter {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
min-width: 2.75rem;
|
||||
padding: var(--file-pad-y) 8px var(--file-pad-y) 10px;
|
||||
text-align: right;
|
||||
color: var(--coollabs-subtle);
|
||||
background: var(--coollabs-recessed);
|
||||
box-shadow: inset -1px 0 0 var(--coollabs-fill);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-ide-gutter > div {
|
||||
height: var(--file-line-height);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.file-ide-gutter > div.is-active {
|
||||
opacity: 1;
|
||||
color: var(--coollabs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .file-ide-gutter > div.is-active {
|
||||
color: var(--warning, #f5c211);
|
||||
}
|
||||
|
||||
.file-ide-code {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-ide-code .file-editor-prosemirror,
|
||||
.file-ide-code .ProseMirror {
|
||||
outline: none;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.file-ide pre {
|
||||
margin: 0;
|
||||
padding: var(--file-pad-y) 16px;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
color: var(--coollabs-fg, #1f2328);
|
||||
}
|
||||
|
||||
.dark .file-ide pre {
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.file-ide-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--coollabs-elevated);
|
||||
box-shadow: inset 0 0 0 1px var(--coollabs-line);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--coollabs-subtle);
|
||||
}
|
||||
|
||||
/* highlight.js tokens (light) */
|
||||
.file-ide .hljs-comment,
|
||||
.file-ide .hljs-quote {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
.file-ide .hljs-keyword,
|
||||
.file-ide .hljs-selector-tag,
|
||||
.file-ide .hljs-literal {
|
||||
color: #cf222e;
|
||||
}
|
||||
.file-ide .hljs-string,
|
||||
.file-ide .hljs-meta .hljs-string {
|
||||
color: #0a3069;
|
||||
}
|
||||
.file-ide .hljs-number,
|
||||
.file-ide .hljs-attr,
|
||||
.file-ide .hljs-property {
|
||||
color: #0550ae;
|
||||
}
|
||||
.file-ide .hljs-title,
|
||||
.file-ide .hljs-title.function_,
|
||||
.file-ide .hljs-section {
|
||||
color: #8250df;
|
||||
}
|
||||
.file-ide .hljs-built_in,
|
||||
.file-ide .hljs-type,
|
||||
.file-ide .hljs-attribute {
|
||||
color: #953800;
|
||||
}
|
||||
.file-ide .hljs-tag,
|
||||
.file-ide .hljs-name,
|
||||
.file-ide .hljs-selector-id,
|
||||
.file-ide .hljs-selector-class {
|
||||
color: #116329;
|
||||
}
|
||||
.file-ide .hljs-meta {
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
/* highlight.js tokens (dark) */
|
||||
.dark .file-ide .hljs-comment,
|
||||
.dark .file-ide .hljs-quote {
|
||||
color: #8b949e;
|
||||
}
|
||||
.dark .file-ide .hljs-keyword,
|
||||
.dark .file-ide .hljs-selector-tag,
|
||||
.dark .file-ide .hljs-literal {
|
||||
color: #ff7b72;
|
||||
}
|
||||
.dark .file-ide .hljs-string,
|
||||
.dark .file-ide .hljs-meta .hljs-string {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
.dark .file-ide .hljs-number,
|
||||
.dark .file-ide .hljs-attr,
|
||||
.dark .file-ide .hljs-property {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .file-ide .hljs-title,
|
||||
.dark .file-ide .hljs-title.function_,
|
||||
.dark .file-ide .hljs-section {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
.dark .file-ide .hljs-built_in,
|
||||
.dark .file-ide .hljs-type,
|
||||
.dark .file-ide .hljs-attribute {
|
||||
color: #ffa657;
|
||||
}
|
||||
.dark .file-ide .hljs-tag,
|
||||
.dark .file-ide .hljs-name,
|
||||
.dark .file-ide .hljs-selector-id,
|
||||
.dark .file-ide .hljs-selector-class {
|
||||
color: #7ee787;
|
||||
}
|
||||
.dark .file-ide .hljs-meta {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.environment-table-scroll {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { initializeCopyButtonComponent } from './copy-button.js';
|
||||
import { initializeTerminalComponent } from './terminal.js';
|
||||
import { initializeFileEditorComponent } from './file-editor.js';
|
||||
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
@@ -20,7 +19,6 @@ document.addEventListener('livewire:navigated', () => {
|
||||
// available before Alpine processes terminal markup after wire:navigate.
|
||||
document.addEventListener('alpine:init', initializeTerminalComponent);
|
||||
document.addEventListener('alpine:init', initializeCopyButtonComponent);
|
||||
document.addEventListener('alpine:init', initializeFileEditorComponent);
|
||||
|
||||
/**
|
||||
* Smooth-scroll a settings section into view, then flash its border for 500ms
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { createLowlight, common } from 'lowlight';
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// The file editor is a single code block (no rich-text nodes) so the whole
|
||||
// file is edited as code with syntax highlighting.
|
||||
const CodeDocument = Document.extend({ content: 'codeBlock' });
|
||||
|
||||
function docFor(text, language) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'codeBlock',
|
||||
attrs: { language: language || null },
|
||||
content: text ? [{ type: 'text', text }] : [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `fileEditor` Alpine component: a TipTap code editor dressed up
|
||||
* like an IDE with a line-number gutter and a Ln/Col status bar. The editor is
|
||||
* recreated fresh each time a file is loaded (content baked into the initial
|
||||
* doc) to avoid ProseMirror "mismatched transaction" errors from the teleported
|
||||
* wire:ignore'd modal. Edits sync back to the Livewire `editorContent` property.
|
||||
*/
|
||||
export function initializeFileEditorComponent() {
|
||||
window.Alpine.data('fileEditor', function fileEditor() {
|
||||
return {
|
||||
editor: null,
|
||||
language: 'plain text',
|
||||
lineCount: 0,
|
||||
activeLine: 0,
|
||||
|
||||
create(text, language) {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.editor = null;
|
||||
}
|
||||
// Clear any orphaned ProseMirror DOM left by a previous instance.
|
||||
this.$refs.editor.innerHTML = '';
|
||||
this.language = language || 'plain text';
|
||||
this.lineCount = 0;
|
||||
this.activeLine = 0;
|
||||
|
||||
this.editor = new Editor({
|
||||
element: this.$refs.editor,
|
||||
extensions: [CodeDocument, Text, CodeBlockLowlight.configure({ lowlight })],
|
||||
editorProps: {
|
||||
attributes: { class: 'file-editor-prosemirror', spellcheck: 'false' },
|
||||
},
|
||||
content: docFor(text, language),
|
||||
onUpdate: ({ editor }) => {
|
||||
this.$wire.set('editorContent', editor.getText({ blockSeparator: '\n' }), false);
|
||||
this.refresh();
|
||||
},
|
||||
onSelectionUpdate: () => this.refresh(),
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
refresh() {
|
||||
if (!this.editor || !this.$refs.gutter) {
|
||||
return;
|
||||
}
|
||||
const full = this.editor.getText({ blockSeparator: '\n' });
|
||||
const lines = full.length ? full.split('\n') : [''];
|
||||
|
||||
// Rebuild the gutter only when the line count changes.
|
||||
if (lines.length !== this.lineCount) {
|
||||
this.lineCount = lines.length;
|
||||
this.$refs.gutter.innerHTML = Array.from(
|
||||
{ length: this.lineCount },
|
||||
(unused, i) => `<div>${i + 1}</div>`,
|
||||
).join('');
|
||||
this.activeLine = 0;
|
||||
}
|
||||
|
||||
// Line/column from the current selection (offset by 1 for the
|
||||
// code block's opening position).
|
||||
const from = this.editor.state.selection.from;
|
||||
const offset = Math.max(0, from - 1);
|
||||
const before = full.slice(0, offset);
|
||||
const line = before.split('\n').length;
|
||||
const col = offset - before.lastIndexOf('\n');
|
||||
|
||||
this.setActive(line);
|
||||
this.$refs.status.textContent = `Ln ${line}, Col ${col}`;
|
||||
this.$refs.statusRight.textContent = `${this.lineCount} ${this.lineCount === 1 ? 'line' : 'lines'} · ${this.language} · spaces`;
|
||||
},
|
||||
|
||||
setActive(line) {
|
||||
if (line === this.activeLine) {
|
||||
return;
|
||||
}
|
||||
const gutter = this.$refs.gutter;
|
||||
const prev = gutter.querySelector('.is-active');
|
||||
if (prev) {
|
||||
prev.classList.remove('is-active');
|
||||
}
|
||||
const cur = gutter.children[line - 1];
|
||||
if (cur) {
|
||||
cur.classList.add('is-active');
|
||||
}
|
||||
this.activeLine = line;
|
||||
},
|
||||
|
||||
load(detail) {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
this.create(detail.content ?? '', detail.language || null);
|
||||
this.$nextTick(() => this.editor && this.editor.commands.focus('end'));
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.editor = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -67,6 +67,7 @@
|
||||
'check' => '<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>',
|
||||
'copy' => '<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke="currentColor" stroke-width="1.5"/>',
|
||||
'chevron-down' => '<g transform="scale(1.33333)"><polyline points="15.25 6.5 9 12.75 2.75 6.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline></g>',
|
||||
'dots-vertical' => '<circle cx="12" cy="5" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="19" r="1.6" fill="currentColor"/>',
|
||||
'trash' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0924 1.25H8.90788C7.33861 1.24998 6.08032 1.24996 5.10577 1.38767C4.09802 1.53007 3.25979 1.83575 2.64218 2.55292C2.02457 3.27008 1.84661 4.14438 1.85528 5.1621C1.86366 6.1463 2.05033 7.39066 2.28314 8.94256L3.49937 17.0508C3.67587 18.2275 3.81878 19.1804 4.02849 19.9262C4.24683 20.7027 4.56045 21.3453 5.13662 21.8415C5.71279 22.3377 6.39485 22.5525 7.19513 22.6533C7.96377 22.75 8.92732 22.75 10.1173 22.75H13.883C15.073 22.75 16.0365 22.75 16.8052 22.6533C17.6054 22.5525 18.2875 22.3377 18.8637 21.8415C19.4398 21.3453 19.7535 20.7027 19.9718 19.9262C20.1815 19.1805 20.3244 18.2276 20.5009 17.0509L21.7172 8.94253C21.95 7.39065 22.1366 6.14629 22.145 5.1621C22.1537 4.14438 21.9757 3.27008 21.3581 2.55292C20.7405 1.83575 19.9023 1.53007 18.8945 1.38767C17.92 1.24996 16.6617 1.24998 15.0924 1.25ZM3.77879 3.53175C4.05882 3.20658 4.47927 2.9911 5.31565 2.87292C6.17295 2.75177 7.32479 2.75 8.96727 2.75H15.033C16.6755 2.75 17.8273 2.75177 18.6846 2.87292C19.521 2.9911 19.9415 3.20658 20.2215 3.53175C20.5015 3.85692 20.6523 4.30468 20.6451 5.14933C20.6448 5.18248 20.6443 5.21604 20.6435 5.25H20.5005C20.5003 5.25 20.5007 5.25 20.5005 5.25H7.00045C7.00025 5.25 7.00065 5.25 7.00045 5.25H3.35678C3.35603 5.21603 3.35551 5.18248 3.35522 5.14933C3.34803 4.30468 3.49876 3.85692 3.77879 3.53175ZM5.18949 6.75H3.48546C3.53687 7.15852 3.60161 7.61096 3.67631 8.1155L3.75015 8.18934L5.18949 6.75ZM4.05013 10.6106L4.6686 14.7338L6.37599 12.9365L4.05013 10.6106ZM5.15659 17.9593C5.17275 18.0594 5.18872 18.1563 5.20463 18.25H5.39887L5.15659 17.9593ZM6.99527 19.75C6.99879 19.75 7.00232 19.75 7.00584 19.75H13.9972C13.9991 19.75 14.0009 19.75 14.0027 19.75H18.4577C18.299 20.2287 18.1176 20.5044 17.8848 20.7049C17.6171 20.9355 17.261 21.0841 16.6178 21.165C15.9538 21.2486 15.0849 21.25 13.833 21.25H10.1673C8.91538 21.25 8.04651 21.2486 7.38247 21.165C6.73934 21.0841 6.38321 20.9355 6.11546 20.7049C5.88266 20.5044 5.70127 20.2287 5.54256 19.75H6.99527ZM15.7131 18.25H18.1895L16.9018 16.9623L15.7131 18.25ZM19.0007 16.9399C19.0087 16.8869 19.0168 16.8332 19.0249 16.7788L19.404 14.2515L17.92 15.8592L19.0007 16.9399ZM19.856 11.2381L20.2249 8.77879C20.3197 8.14673 20.4033 7.5881 20.4704 7.09045L18.16 9.40079L19.856 11.2381ZM18.6895 6.75H15.7131L17.1418 8.2977L18.6895 6.75ZM12.2532 6.75H8.81081L10.5761 8.51531L12.2532 6.75ZM11.6895 18.25H9.31081L10.5002 17.0607L11.6895 18.25ZM7.40946 11.8486L4.81081 9.25L7.00015 7.06066L9.54266 9.60317L7.40946 11.8486ZM8.47047 12.9097L10.6037 10.6642L12.6895 12.75L10.5002 14.9393L8.47047 12.9097ZM11.5608 16L13.7502 13.8107L15.8403 15.9008L13.7385 18.1777L11.5608 16ZM14.8108 12.75L16.8585 14.7977L18.9795 12.5L17.0985 10.4623L14.8108 12.75ZM13.7502 11.6893L16.0803 9.35921L13.9923 7.09721L11.6371 9.57632L13.7502 11.6893ZM7.437 13.9975L9.43949 16L7.27782 18.1617L5.50363 16.0326L7.437 13.9975Z" fill="currentColor"/>',
|
||||
'external-link' => '<path d="M13 11L21.2 2.80005" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 6.8V2H17.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22H15C20 22 22 20 22 15V13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'server-update' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M9.94358 2.25H14.0564C15.8942 2.24998 17.3498 2.24997 18.489 2.40314C19.6614 2.56076 20.6104 2.89288 21.3588 3.64124C22.1071 4.38961 22.4392 5.33856 22.5969 6.51098C22.75 7.65019 22.75 9.10583 22.75 10.9436V13C22.75 13.4142 22.4142 13.75 22 13.75C21.5858 13.75 21.25 13.4142 21.25 13V12.75H2.75V13C2.75 14.9068 2.75159 16.2615 2.88976 17.2892C3.02502 18.2952 3.27869 18.8749 3.7019 19.2981C4.12511 19.7213 4.70476 19.975 5.71085 20.1102C6.73851 20.2484 8.09318 20.25 10 20.25H13C13.4142 20.25 13.75 20.5858 13.75 21C13.75 21.4142 13.4142 21.75 13 21.75H9.94359C8.10583 21.75 6.65019 21.75 5.51098 21.5969C4.33856 21.4392 3.38961 21.1071 2.64124 20.3588C1.89288 19.6104 1.56076 18.6614 1.40314 17.489C1.24997 16.3498 1.24998 14.8942 1.25 13.0564V10.9436C1.24998 9.10582 1.24997 7.65019 1.40314 6.51098C1.56076 5.33856 1.89288 4.38961 2.64124 3.64124C3.38961 2.89288 4.33856 2.56076 5.51098 2.40314C6.65019 2.24997 8.10582 2.24998 9.94358 2.25ZM2.75 11.25H21.25V11C21.25 9.09318 21.2484 7.73851 21.1102 6.71085C20.975 5.70476 20.7213 5.12511 20.2981 4.7019C19.8749 4.27869 19.2952 4.02502 18.2892 3.88976C17.2615 3.75159 15.9068 3.75 14 3.75H10C8.09318 3.75 6.73851 3.75159 5.71085 3.88976C4.70476 4.02502 4.12511 4.27869 3.7019 4.7019C3.27869 5.12511 3.02502 5.70476 2.88976 6.71085C2.75159 7.73851 2.75 9.09318 2.75 11V11.25ZM6 5.75C6.41421 5.75 6.75 6.08579 6.75 6.5V8.5C6.75 8.91421 6.41421 9.25 6 9.25C5.58579 9.25 5.25 8.91421 5.25 8.5V6.5C5.25 6.08579 5.58579 5.75 6 5.75ZM9 5.75C9.41421 5.75 9.75 6.08579 9.75 6.5V8.5C9.75 8.91421 9.41421 9.25 9 9.25C8.58579 9.25 8.25 8.91421 8.25 8.5V6.5C8.25 6.08579 8.58579 5.75 9 5.75ZM12.75 7.5C12.75 7.08579 13.0858 6.75 13.5 6.75H18C18.4142 6.75 18.75 7.08579 18.75 7.5C18.75 7.91421 18.4142 8.25 18 8.25H13.5C13.0858 8.25 12.75 7.91421 12.75 7.5ZM14.9286 16.7534C15.3176 15.2559 16.8529 14.25 18.5048 14.25C19.782 14.25 20.945 14.8304 21.6108 15.7648C21.8512 16.1021 21.7726 16.5704 21.4352 16.8108C21.0979 17.0512 20.6296 16.9726 20.3892 16.6352C20.0295 16.1304 19.3411 15.75 18.5048 15.75C17.5383 15.75 16.8181 16.2319 16.5056 16.8299C16.592 16.8734 16.6713 16.9343 16.738 17.0123C17.0074 17.327 16.9706 17.8004 16.656 18.0698L16.0718 18.5698C15.7911 18.8101 15.3772 18.8101 15.0964 18.5698L14.5123 18.0698C14.1976 17.8004 14.1609 17.327 14.4302 17.0123C14.5618 16.8586 14.742 16.7712 14.9286 16.7534ZM6 14.75C6.41421 14.75 6.75 15.0858 6.75 15.5V17.5C6.75 17.9142 6.41421 18.25 6 18.25C5.58579 18.25 5.25 17.9142 5.25 17.5V15.5C5.25 15.0858 5.58579 14.75 6 14.75ZM9 14.75C9.41421 14.75 9.75 15.0858 9.75 15.5V17.5C9.75 17.9142 9.41421 18.25 9 18.25C8.58579 18.25 8.25 17.9142 8.25 17.5V15.5C8.25 15.0858 8.58579 14.75 9 14.75ZM20.9282 17.4302C21.2089 17.1899 21.6228 17.1899 21.9036 17.4302L22.4877 17.9302C22.8024 18.1996 22.8391 18.673 22.5698 18.9877C22.4382 19.1414 22.258 19.2288 22.0714 19.2466C21.6824 20.7441 20.1471 21.75 18.4952 21.75C17.218 21.75 16.055 21.1696 15.3892 20.2352C15.1488 19.8979 15.2274 19.4296 15.5648 19.1892C15.9021 18.9488 16.3704 19.0274 16.6108 19.3648C16.9705 19.8696 17.6589 20.25 18.4952 20.25C19.4617 20.25 20.1819 19.7681 20.4944 19.1701C20.408 19.1266 20.3287 19.0657 20.262 18.9877C19.9926 18.673 20.0294 18.1996 20.344 17.9302L20.9282 17.4302Z" fill="currentColor"/>',
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
<span>Perms</span>
|
||||
<span>Owner</span>
|
||||
<span>Group</span>
|
||||
<span class="text-right">Size</span>
|
||||
<span>Modified</span>
|
||||
<span class="text-right">Actions</span>
|
||||
@@ -187,61 +189,88 @@
|
||||
</form>
|
||||
</x-modal-input>
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-coollabs-subtle">
|
||||
{{ $entry['owner'] ?: '-' }}
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-coollabs-subtle">
|
||||
{{ $entry['group'] ?: '-' }}
|
||||
</div>
|
||||
<div class="text-right text-coollabs-subtle tabular-nums">
|
||||
{{ $entry['type'] === 'dir' ? '-' : formatBytes($entry['size']) }}
|
||||
</div>
|
||||
<div class="text-coollabs-subtle tabular-nums">
|
||||
{{ $entry['mtime'] ? \Illuminate\Support\Carbon::createFromTimestamp($entry['mtime'])->toDateTimeString() : '-' }}
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
@if ($entry['type'] !== 'dir')
|
||||
<x-forms.button type="button" title="Edit"
|
||||
x-on:click="$wire.openEditor(@js($entry['name']))">
|
||||
<x-reicon name="file-content" class="size-3.5" />
|
||||
{{-- Actions kebab. The entry name lives in this
|
||||
row's x-data as a JS string so the menu can
|
||||
call $wire.* with it directly - passing @js()
|
||||
through a component attribute double-encodes
|
||||
the quotes and breaks the handler. --}}
|
||||
<div class="flex items-center justify-end"
|
||||
x-data="{ open: false, name: @js($entry['name']) }"
|
||||
@keydown.escape.window="open = false">
|
||||
<div class="relative">
|
||||
<x-forms.button type="button" title="Actions"
|
||||
x-on:click="open = !open" @click.outside="open = false"
|
||||
x-bind:class="open && 'bg-coollabs/10 text-coollabs ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25'">
|
||||
<x-reicon name="dots-vertical" class="size-3.5" />
|
||||
</x-forms.button>
|
||||
@endif
|
||||
<x-forms.button type="button" title="Download"
|
||||
x-on:click="$wire.download(@js($entry['name']))">
|
||||
<x-reicon name="download" class="size-3.5" />
|
||||
</x-forms.button>
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
|
||||
class="listbox-panel right-0! left-auto! z-30!">
|
||||
@if ($entry['type'] !== 'dir')
|
||||
<button type="button" class="listbox-option"
|
||||
x-on:click="open = false; $wire.openEditor(name)">
|
||||
<x-reicon name="file-content" class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 text-left">Edit</span>
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" class="listbox-option"
|
||||
x-on:click="open = false; $wire.download(name)">
|
||||
<x-reicon name="download" class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 text-left">Download</span>
|
||||
</button>
|
||||
|
||||
{{-- Rename --}}
|
||||
<x-modal-input title="Rename">
|
||||
<x-slot:content>
|
||||
<x-forms.button type="button" title="Rename">
|
||||
<x-reicon name="edit" class="size-3.5" />
|
||||
</x-forms.button>
|
||||
</x-slot:content>
|
||||
<form x-data="{ name: @js($entry['name']) }"
|
||||
@submit.prevent="$wire.renameEntry(@js($entry['name']), name); $dispatch('close-modal')"
|
||||
class="flex flex-col gap-4">
|
||||
<x-forms.input id="renameName-{{ $loop->index }}"
|
||||
label="New name" x-model="name" required />
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button type="submit" isHighlighted>Rename</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
{{-- Rename --}}
|
||||
<x-modal-input title="Rename">
|
||||
<x-slot:content>
|
||||
<button type="button" class="listbox-option" x-on:click="open = false">
|
||||
<x-reicon name="edit" class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 text-left">Rename</span>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<form x-data="{ newName: @js($entry['name']) }"
|
||||
@submit.prevent="$wire.renameEntry(name, newName); $dispatch('close-modal')"
|
||||
class="flex flex-col gap-4">
|
||||
<x-forms.input id="renameName-{{ $loop->index }}"
|
||||
label="New name" x-model="newName" required />
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button type="submit" isHighlighted>Rename</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
|
||||
{{-- Delete --}}
|
||||
<x-modal-input title="Delete this item?">
|
||||
<x-slot:content>
|
||||
<x-forms.button isError type="button" title="Delete">
|
||||
<x-reicon name="trash" class="size-3.5" />
|
||||
</x-forms.button>
|
||||
</x-slot:content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-coollabs-subtle">
|
||||
This permanently deletes
|
||||
<span class="font-medium">{{ $entry['name'] }}</span>
|
||||
inside the container. This cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button isError type="button"
|
||||
x-on:click="$wire.deleteEntry(@js($entry['name'])); $dispatch('close-modal')">Delete</x-forms.button>
|
||||
</div>
|
||||
{{-- Delete --}}
|
||||
<x-modal-input title="Delete this item?">
|
||||
<x-slot:content>
|
||||
<button type="button" class="listbox-option" x-on:click="open = false">
|
||||
<x-reicon name="trash" class="size-3.5 shrink-0 text-red-600 dark:text-red-500" />
|
||||
<span class="min-w-0 flex-1 text-left text-red-600 dark:text-red-500">Delete</span>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-coollabs-subtle">
|
||||
This permanently deletes
|
||||
<span class="font-medium">{{ $entry['name'] }}</span>
|
||||
inside the container. This cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button isError type="button"
|
||||
x-on:click="$wire.deleteEntry(name); $dispatch('close-modal')">Delete</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@@ -261,20 +290,68 @@
|
||||
<span class="shrink-0 rounded-md bg-coollabs/10 px-1.5 py-0.5 font-mono text-[11px] text-coollabs dark:bg-warning/15 dark:text-warning">{{ $editorLanguage }}</span>
|
||||
@endif
|
||||
</div>
|
||||
{{-- TipTap code editor (single code block, lowlight highlighting).
|
||||
wire:ignore protects the ProseMirror DOM from Livewire morphs. --}}
|
||||
<div wire:ignore x-data="fileEditor" x-on:load-file-editor.window="load($event.detail)"
|
||||
class="min-w-0">
|
||||
<div class="file-ide">
|
||||
<div class="file-ide-gutter" x-ref="gutter" aria-hidden="true"></div>
|
||||
<div class="file-ide-code">
|
||||
<div x-ref="editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-ide-status">
|
||||
<span x-ref="status">Ln 1, Col 1</span>
|
||||
<span x-ref="statusRight" class="ml-auto"></span>
|
||||
</div>
|
||||
{{-- Monaco editor, kept mounted across opens (wire:ignore) and
|
||||
preloaded on page init so the first Edit click is instant.
|
||||
openEditor dispatches load-file-editor with the content and
|
||||
Monaco language id; we setValue + setModelLanguage without
|
||||
recreating, so highlighting applies and there is no flash.
|
||||
Inline Alpine (like x-forms.monaco-editor) means this works
|
||||
without a JS rebuild. --}}
|
||||
<div wire:ignore class="min-w-0" x-init="loadMonaco()"
|
||||
x-on:load-file-editor.window="load($event.detail)"
|
||||
x-data="{
|
||||
vs: '/js/monaco-editor-0.52.2/min/vs',
|
||||
base: '/js/monaco-editor-0.52.2/min',
|
||||
isDark() {
|
||||
return document.documentElement.classList.contains('dark') || localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
},
|
||||
loadMonaco() {
|
||||
if (window.__fileEditorMonaco) return window.__fileEditorMonaco;
|
||||
const vs = this.vs, base = this.base;
|
||||
window.__fileEditorMonaco = new Promise((resolve, reject) => {
|
||||
const boot = () => {
|
||||
window.require.config({ paths: { vs } });
|
||||
const proxy = URL.createObjectURL(new Blob([`self.MonacoEnvironment={baseUrl:'${window.location.origin}${base}'};importScripts('${window.location.origin}${vs}/base/worker/workerMain.js');`], { type: 'text/javascript' }));
|
||||
window.MonacoEnvironment = { getWorkerUrl: () => proxy };
|
||||
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
||||
};
|
||||
if (typeof window.require !== 'undefined' && window.require.config) { boot(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = `${vs}/loader.js`; s.onload = boot; s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return window.__fileEditorMonaco;
|
||||
},
|
||||
ensureTheme(monaco) {
|
||||
if (window.__fileEditorThemeDefined) return;
|
||||
monaco.editor.defineTheme('coolify-dark', { base: 'vs-dark', inherit: true, rules: [], colors: { 'editor.background': '#0b0b0c', 'editorGutter.background': '#0b0b0c', 'minimap.background': '#0b0b0c' } });
|
||||
window.__fileEditorThemeDefined = true;
|
||||
},
|
||||
async load(detail) {
|
||||
const monaco = await this.loadMonaco();
|
||||
this.ensureTheme(monaco);
|
||||
const el = this.$refs.editor;
|
||||
const wire = this.$wire;
|
||||
const content = (detail && detail.content) || '';
|
||||
const language = (detail && detail.language) || 'plaintext';
|
||||
// Keep the Monaco instance on the DOM node, NOT in
|
||||
// Alpine reactive state - proxying its huge object
|
||||
// graph would hang and crash the tab.
|
||||
if (!el._monacoEditor) {
|
||||
const editor = monaco.editor.create(el, { value: content, language, theme: this.isDark() ? 'coolify-dark' : 'vs', automaticLayout: true, minimap: { enabled: false }, fontSize: 14, lineNumbersMinChars: 3, scrollBeyondLastLine: false, renderLineHighlight: 'all', padding: { top: 12, bottom: 12 }, scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8, useShadows: false } });
|
||||
el._monacoEditor = editor;
|
||||
editor.onDidChangeModelContent(() => { wire.set('editorContent', editor.getValue(), false); });
|
||||
new MutationObserver(() => monaco.editor.setTheme(this.isDark() ? 'coolify-dark' : 'vs')).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
} else {
|
||||
const model = el._monacoEditor.getModel();
|
||||
model.setValue(content);
|
||||
monaco.editor.setModelLanguage(model, language);
|
||||
}
|
||||
const editor = el._monacoEditor;
|
||||
this.$nextTick(() => { editor.layout(); setTimeout(() => editor.focus(), 40); });
|
||||
}
|
||||
}">
|
||||
<div x-ref="editor" class="file-editor-monaco"></div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<x-forms.button type="button" wire:click="closeEditor"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Data\FileEntry;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
@@ -51,13 +50,18 @@ it('sorts directories before files, then by name case-insensitively', function (
|
||||
expect($names)->toBe(['assets', 'src', '.env', 'README.md']);
|
||||
});
|
||||
|
||||
it('builds a listing command with an escaped path and the container name', function () {
|
||||
it('builds a listing command with a single stat call, escaped path and container name', function () {
|
||||
$cmd = fsService()->buildListCommand('/var/www/html');
|
||||
|
||||
expect($cmd)
|
||||
->toContain('docker exec')
|
||||
->toContain('app-123')
|
||||
->toContain(escapeshellarg('/var/www/html'));
|
||||
->toContain('stat -c')
|
||||
->toContain(escapeshellarg('/var/www/html'))
|
||||
// Must embed a REAL tab, not a literal backslash-t: stat -c does not
|
||||
// expand \t, so a literal escape would break the tab-delimited parse.
|
||||
->toContain("%F\t%s")
|
||||
->not->toContain('%F\\t%s');
|
||||
});
|
||||
|
||||
it('rejects an unsafe listing path', function () {
|
||||
@@ -103,20 +107,23 @@ it('falls back to / when the container has no WorkingDir', function () {
|
||||
});
|
||||
|
||||
it('refuses to read a file larger than the edit cap', function () {
|
||||
$tooBig = (string) (LocalFileVolume::MAX_CONTENT_SIZE + 1);
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: $tooBig)) // stat size
|
||||
->push(Process::result(output: 'text'))]); // grep -qI (unused here)
|
||||
// The combined read command emits TOOBIG in a single round trip.
|
||||
Process::fake(['*' => Process::result(output: 'TOOBIG')]);
|
||||
|
||||
$server = fsServer();
|
||||
(new ContainerFilesystemService($server, 'app-123'))->read('/app/big.bin');
|
||||
})->throws(RuntimeException::class);
|
||||
|
||||
it('reads an editable text file', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '12')) // stat size
|
||||
->push(Process::result(output: 'text')) // binary check => text
|
||||
->push(Process::result(output: base64_encode("hello world\n")))]); // base64 read
|
||||
it('refuses to read a binary file', function () {
|
||||
Process::fake(['*' => Process::result(output: 'BINARY')]);
|
||||
|
||||
$server = fsServer();
|
||||
(new ContainerFilesystemService($server, 'app-123'))->read('/app/a.bin');
|
||||
})->throws(RuntimeException::class);
|
||||
|
||||
it('reads an editable text file in a single round trip', function () {
|
||||
// "OK" header line then the base64 payload, from one docker exec.
|
||||
Process::fake(['*' => Process::result(output: "OK\n".base64_encode("hello world\n"))]);
|
||||
|
||||
$server = fsServer();
|
||||
$content = (new ContainerFilesystemService($server, 'app-123'))->read('/app/a.txt');
|
||||
@@ -125,9 +132,8 @@ it('reads an editable text file', function () {
|
||||
});
|
||||
|
||||
it('treats an empty file as editable text', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '0')) // stat size => 0
|
||||
->push(Process::result(output: ''))]); // base64 read (empty)
|
||||
// Empty file => "OK" header with no payload.
|
||||
Process::fake(['*' => Process::result(output: 'OK')]);
|
||||
|
||||
$server = fsServer();
|
||||
|
||||
@@ -171,6 +177,30 @@ it('rejects unsafe paths in mutating builders', function () {
|
||||
fsService()->buildDeleteCommand('/app/$(reboot)');
|
||||
})->throws(Exception::class);
|
||||
|
||||
it('parses the 7-field stat listing with owner and group, mapping the file type', function () {
|
||||
$raw = implode("\n", [
|
||||
"directory\t4096\t1700000000\t755\troot\troot\t.",
|
||||
"directory\t4096\t1700000000\t755\troot\troot\t..",
|
||||
"regular file\t497\t1700000001\t644\twww-data\twww-data\t50x.html",
|
||||
"directory\t4096\t1700000002\t755\tapp\tapp\tassets",
|
||||
"symbolic link\t11\t1700000003\t777\troot\troot\tlink",
|
||||
]);
|
||||
|
||||
$entries = fsService()->parseListing($raw);
|
||||
|
||||
// The stat . and .. rows are dropped.
|
||||
expect($entries)->toHaveCount(3);
|
||||
expect($entries[0]->name)->toBe('assets');
|
||||
expect($entries[0]->type)->toBe('dir');
|
||||
expect($entries[0]->owner)->toBe('app');
|
||||
expect($entries[0]->group)->toBe('app');
|
||||
expect($entries[1]->name)->toBe('50x.html');
|
||||
expect($entries[1]->type)->toBe('file');
|
||||
expect($entries[1]->owner)->toBe('www-data');
|
||||
expect($entries[2]->name)->toBe('link');
|
||||
expect($entries[2]->type)->toBe('symlink');
|
||||
});
|
||||
|
||||
it('parses permissions from the 5-field listing format', function () {
|
||||
$raw = "file\t497\t1700000000\t644\t50x.html\ndir\t0\t1700000001\t755\tassets";
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Livewire\Project\Shared\FileBrowser;
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
@@ -181,13 +180,11 @@ it('changes permissions and re-lists', function () {
|
||||
->assertSee('755');
|
||||
});
|
||||
|
||||
it('sets the editor language from the file extension when opening', function () {
|
||||
it('sets the editor language and content from the file when opening', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '/data')) // defaultRoot
|
||||
->push(Process::result(output: "file\t12\t1\t644\tconfig.yml")) // initial list
|
||||
->push(Process::result(output: '12')) // isEditable stat size
|
||||
->push(Process::result(output: 'text')) // binary check
|
||||
->push(Process::result(output: base64_encode("a: 1\n")))]); // base64 read
|
||||
->push(Process::result(output: "OK\n".base64_encode("a: 1\n")))]); // single-round-trip read
|
||||
|
||||
$database = fbRunningDatabase($this->environment, $this->destination);
|
||||
$this->actingAs($this->admin);
|
||||
@@ -201,12 +198,26 @@ it('sets the editor language from the file extension when opening', function ()
|
||||
->assertDispatched('load-file-editor', content: "a: 1\n", language: 'yaml');
|
||||
});
|
||||
|
||||
it('resolves a Monaco language for common extension-less files', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '/data')) // defaultRoot
|
||||
->push(Process::result(output: "file\t12\t1\t644\tDockerfile")) // initial list
|
||||
->push(Process::result(output: "OK\n".base64_encode("FROM alpine\n")))]); // read
|
||||
|
||||
$database = fbRunningDatabase($this->environment, $this->destination);
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(FileBrowser::class, ['resource' => $database])
|
||||
->call('openEditor', 'Dockerfile')
|
||||
->assertSet('editorLanguage', 'dockerfile');
|
||||
});
|
||||
|
||||
it('refuses to open a binary or oversized file in the editor', function () {
|
||||
$tooBig = (string) (LocalFileVolume::MAX_CONTENT_SIZE + 1);
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '/data')) // defaultRoot
|
||||
->push(Process::result(output: "file\t99999999\t1\tbig.bin")) // initial list
|
||||
->push(Process::result(output: $tooBig))]); // isEditable stat
|
||||
->push(Process::result(output: 'TOOBIG'))]); // single-round-trip read
|
||||
|
||||
$database = fbRunningDatabase($this->environment, $this->destination);
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Reference in New Issue
Block a user