diff --git a/app/Data/FileEntry.php b/app/Data/FileEntry.php index 3600d04ac7..7e01793ea3 100644 --- a/app/Data/FileEntry.php +++ b/app/Data/FileEntry.php @@ -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 diff --git a/app/Livewire/Project/Shared/FileBrowser.php b/app/Livewire/Project/Shared/FileBrowser.php index 9cba219ce6..f11803d4af 100644 --- a/app/Livewire/Project/Shared/FileBrowser.php +++ b/app/Livewire/Project/Shared/FileBrowser.php @@ -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); } diff --git a/app/Services/ContainerFilesystemService.php b/app/Services/ContainerFilesystemService.php index 8f3225277a..d062d60423 100644 --- a/app/Services/ContainerFilesystemService.php +++ b/app/Services/ContainerFilesystemService.php @@ -25,20 +25,18 @@ class ContainerFilesystemService { $escapedPath = $this->escapePath($path, 'list path'); - // Iterate `* .*`, skip . and .., guard literal globs, emit - // typesizemtimepermsname. 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: typesizemtimepermsownergroupname. + // 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'); diff --git a/package-lock.json b/package-lock.json index a82274ef3d..e5802a0b13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index 257bed8e63..42f39d29ea 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/resources/css/app.css b/resources/css/app.css index ede8910efc..06fb672538 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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; diff --git a/resources/js/app.js b/resources/js/app.js index 43dcadcfc5..9315daa449 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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 diff --git a/resources/js/file-editor.js b/resources/js/file-editor.js deleted file mode 100644 index b7420696e3..0000000000 --- a/resources/js/file-editor.js +++ /dev/null @@ -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) => `
${i + 1}
`, - ).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; - } - }, - }; - }); -} diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index b929cf6940..f3f7fa3f48 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -67,6 +67,7 @@ 'check' => '', 'copy' => '', 'chevron-down' => '', + 'dots-vertical' => '', 'trash' => '', 'external-link' => '', 'server-update' => '', diff --git a/resources/views/livewire/project/shared/file-browser.blade.php b/resources/views/livewire/project/shared/file-browser.blade.php index a310153749..2343e4792a 100644 --- a/resources/views/livewire/project/shared/file-browser.blade.php +++ b/resources/views/livewire/project/shared/file-browser.blade.php @@ -145,6 +145,8 @@ Name Perms + Owner + Group Size Modified Actions @@ -187,61 +189,88 @@ +
+ {{ $entry['owner'] ?: '-' }} +
+
+ {{ $entry['group'] ?: '-' }} +
{{ $entry['type'] === 'dir' ? '-' : formatBytes($entry['size']) }}
{{ $entry['mtime'] ? \Illuminate\Support\Carbon::createFromTimestamp($entry['mtime'])->toDateTimeString() : '-' }}
-
- @if ($entry['type'] !== 'dir') - - + {{-- 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. --}} +
+
+ + - @endif - - - +
+ @if ($entry['type'] !== 'dir') + + @endif + - {{-- Rename --}} - - - - - - -
- -
- Rename -
- -
+ {{-- Rename --}} + + + + +
+ +
+ Rename +
+ +
- {{-- Delete --}} - - - - - - -
-

- This permanently deletes - {{ $entry['name'] }} - inside the container. This cannot be undone. -

-
- Delete -
+ {{-- Delete --}} + + + + +
+

+ This permanently deletes + {{ $entry['name'] }} + inside the container. This cannot be undone. +

+
+ Delete +
+
+
-
+
@endforeach @@ -261,20 +290,68 @@ {{ $editorLanguage }} @endif
- {{-- TipTap code editor (single code block, lowlight highlighting). - wire:ignore protects the ProseMirror DOM from Livewire morphs. --}} -
-
- -
-
-
-
-
- Ln 1, Col 1 - -
+ {{-- 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. --}} +
+
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"; diff --git a/tests/Feature/Livewire/FileBrowserTest.php b/tests/Feature/Livewire/FileBrowserTest.php index 9034c5f52d..b16f847efa 100644 --- a/tests/Feature/Livewire/FileBrowserTest.php +++ b/tests/Feature/Livewire/FileBrowserTest.php @@ -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);