diff --git a/docs/FILE_ICONS.md b/docs/FILE_ICONS.md new file mode 100644 index 000000000..96cedfb13 --- /dev/null +++ b/docs/FILE_ICONS.md @@ -0,0 +1,109 @@ +# File Icons + +The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`). + +Icons are inlined as SVG strings in: + +``` +packages/app/src/components/material-file-icons.ts +``` + +This file is auto-generated. Do not edit it by hand. + +## How it works + +- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings +- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names +- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon + +## Adding a new icon + +1. Find the icon name in the material-icon-theme manifest: + +```bash +node -e " +const m = require('./node_modules/material-icon-theme/dist/material-icons.json'); +console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']); +console.log('languageIds:', m.languageIds['YOUR_LANG']); +" +``` + +2. Verify the SVG exists: + +```bash +cat node_modules/material-icon-theme/icons/ICON_NAME.svg +``` + +3. Add two things to `material-file-icons.ts`: + + - The SVG string in `SVG_ICONS`: + ```ts + "icon_name": `...`, + ``` + + - The extension mapping in `EXTENSION_TO_ICON`: + ```ts + "ext": "icon_name", + ``` + +4. Run `npm run typecheck` to verify. + +## Currently included icons + +53 unique icons covering these extensions: + +| Extension(s) | Icon | +|---|---| +| `ts` | typescript | +| `tsx` | react_ts | +| `js` | javascript | +| `jsx` | react | +| `py` | python | +| `go` | go | +| `rs` | rust | +| `rb` | ruby | +| `java` | java | +| `kt` | kotlin | +| `c` | c | +| `cpp` | cpp | +| `h` | h | +| `hpp` | hpp | +| `cs` | csharp | +| `swift` | swift | +| `dart` | dart | +| `ex`, `exs` | elixir | +| `erl` | erlang | +| `hs` | haskell | +| `clj` | clojure | +| `scala` | scala | +| `ml` | ocaml | +| `r` | r | +| `lua` | lua | +| `zig` | zig | +| `nix` | nix | +| `php` | php | +| `html` | html | +| `css` | css | +| `scss` | sass | +| `less` | less | +| `json` | json | +| `yml`, `yaml` | yaml | +| `xml` | xml | +| `toml` | toml | +| `md`, `markdown` | markdown | +| `sql` | database | +| `graphql`, `gql` | graphql | +| `sh`, `bash` | console | +| `tf` | terraform | +| `hcl` | hcl | +| `vue` | vue | +| `svelte` | svelte | +| `astro` | astro | +| `wasm` | webassembly | +| `svg` | svg | +| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image | +| `txt` | document | +| `conf`, `cfg`, `ini` | settings | +| `lock` | lock | +| `groovy` | groovy | +| `gradle` | gradle | diff --git a/package-lock.json b/package-lock.json index c56fc40f0..04e42e33c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14831,6 +14831,13 @@ "node": ">=10" } }, + "node_modules/chroma-js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", + "dev": true, + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -15855,6 +15862,33 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-rename-keys": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz", + "integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2", + "rename-keys": "^1.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-rename-keys/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -18423,6 +18457,13 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "dev": true, + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -25292,6 +25333,25 @@ "node": ">=10" } }, + "node_modules/material-icon-theme": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.32.0.tgz", + "integrity": "sha512-SxJxCcnk6cJIbd+AxmoeghXJ24joXGmUzjiGci16sX4mXZdXprGEzM6ZZ0VHGAofxNlMqznEbExINwFLsxf8eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chroma-js": "^3.1.2", + "events": "^3.3.0", + "fast-deep-equal": "^3.1.3", + "svgson": "^5.3.1" + }, + "engines": { + "vscode": "^1.55.0" + }, + "funding": { + "url": "https://github.com/sponsors/material-extensions" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -30156,6 +30216,16 @@ "dev": true, "license": "MIT" }, + "node_modules/rename-keys": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz", + "integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -32144,6 +32214,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svgson": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz", + "integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-rename-keys": "^0.2.1", + "xml-reader": "2.4.3" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -34550,6 +34631,16 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/xml-lexer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz", + "integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^2.0.0" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -34560,6 +34651,17 @@ "node": ">=12" } }, + "node_modules/xml-reader": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz", + "integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^2.0.0", + "xml-lexer": "^0.2.2" + } + }, "node_modules/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", @@ -34947,6 +35049,7 @@ "eas-cli": "^16.24.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", "typescript": "~5.9.2", "vitest": "^3.2.4", diff --git a/packages/app/package.json b/packages/app/package.json index c2790038b..97706dd58 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -111,6 +111,7 @@ "eas-cli": "^16.24.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", "typescript": "~5.9.2", "vitest": "^3.2.4", diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index a030dc8b0..5c0632072 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -26,18 +26,17 @@ import Animated, { import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; +import { SvgXml } from "react-native-svg"; import { + ChevronDown, + ChevronRight, Copy, Download, - File, - FileText, - Folder, - FolderOpen, - Image as ImageIcon, MoreVertical, RotateCw, X, } from "lucide-react-native"; +import { getFileIconSvg } from "@/components/material-file-icons"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; @@ -65,7 +64,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "size", label: "Size" }, ]; -const INDENT_PER_LEVEL = 12; +const INDENT_PER_LEVEL = 16; function formatFileSize({ size }: { size: number }): string { if (size < 1024) { @@ -384,7 +383,6 @@ export function FileExplorerPane({ ({ item }: ListRenderItemInfo) => { const entry = item.entry; const depth = item.depth; - const displayKind = getEntryDisplayKind(entry); const isDirectory = entry.kind === "directory"; const isExpanded = isDirectory && expandedPaths.has(entry.path); const isSelected = selectedEntryPath === entry.path; @@ -399,16 +397,30 @@ export function FileExplorerPane({ (hovered || pressed || isSelected) && styles.entryRowActive, ]} > + {depth > 0 && + Array.from({ length: depth }, (_, i) => ( + + ))} - {loading ? ( - + {isDirectory ? ( + loading ? ( + + ) : ( + + + + ) ) : ( - renderEntryIcon(isDirectory ? "directory" : displayKind, { - foreground: theme.colors.foregroundMuted, - primary: theme.colors.primary, - directoryOpen: isExpanded, - }) + )} @@ -552,27 +564,31 @@ export function FileExplorerPane({ ) : ( - - - [ - styles.iconButton, - (hovered || pressed) && styles.iconButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Refresh files" - > - - - - - - {currentSortLabel} - - + [ + styles.sortTrigger, + (hovered || pressed) && styles.sortTriggerHovered, + ]} + > + {currentSortLabel} + + + [ + styles.iconButton, + (hovered || pressed) && styles.iconButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Refresh files" + > + + + + - ) : ( - - ); - case "image": - return ; - case "text": - return ; - default: - return ; - } -} - -function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind { - if (entry.kind === "directory") { - return "directory"; - } - - const extension = getExtension(entry.name); - if (extension === null) { - return "other"; - } - - if (IMAGE_EXTENSIONS.has(extension)) { - return "image"; - } - - if (TEXT_EXTENSIONS.has(extension)) { - return "text"; - } - - return "other"; -} - -function getExtension(name: string): string | null { - const index = name.lastIndexOf("."); - if (index === -1 || index === name.length - 1) { - return null; - } - return name.slice(index + 1).toLowerCase(); -} - function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { const sorted = [...entries]; sorted.sort((a, b) => { @@ -842,50 +765,38 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, paneHeader: { + height: WORKSPACE_SECONDARY_HEADER_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "space-between", - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - paddingHorizontal: theme.spacing[3], + paddingRight: theme.spacing[3], borderBottomWidth: 1, borderBottomColor: theme.colors.border, - backgroundColor: theme.colors.surfaceSidebar, }, - paneHeaderLeft: { - flex: 1, - minWidth: 0, - }, - paneHeaderRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - previewHeaderRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - sortButton: { - height: 28, + sortTrigger: { flexDirection: "row", alignItems: "center", justifyContent: "center", - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, + gap: theme.spacing[1], + marginLeft: theme.spacing[3] - theme.spacing[1], + paddingHorizontal: theme.spacing[1], + height: 24, + borderRadius: theme.borderRadius.base, }, - sortButtonText: { - color: theme.colors.foregroundMuted, + sortTriggerHovered: { + backgroundColor: theme.colors.surface2, + }, + sortTriggerText: { fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, }, treeList: { flex: 1, minHeight: 0, }, entriesContent: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, centerState: { @@ -936,8 +847,16 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "space-between", paddingVertical: 2, paddingRight: theme.spacing[2], + borderRadius: theme.borderRadius.md, }, entryRowActive: { + backgroundColor: theme.colors.surface1, + }, + indentGuide: { + position: "absolute", + top: 0, + bottom: 0, + width: 1, backgroundColor: theme.colors.surface2, }, entryInfo: { @@ -947,6 +866,16 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], minWidth: 0, }, + chevron: { + width: 16, + height: 16, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, entryIcon: { flexShrink: 0, }, diff --git a/packages/app/src/components/material-file-icons.ts b/packages/app/src/components/material-file-icons.ts new file mode 100644 index 000000000..c683dbc84 --- /dev/null +++ b/packages/app/src/components/material-file-icons.ts @@ -0,0 +1,141 @@ +// Auto-generated from material-icon-theme. Do not edit manually. + +const SVG_ICONS: Record = { + "_default": ``, + "astro": ``, + "c": ``, + "clojure": ``, + "console": ``, + "cpp": ``, + "csharp": ``, + "css": ``, + "dart": ``, + "database": ``, + "document": ``, + "elixir": ``, + "erlang": ``, + "go": ``, + "gradle": ``, + "graphql": ``, + "groovy": ``, + "h": ``, + "haskell": ``, + "hcl": ``, + "hpp": ``, + "html": ``, + "image": ``, + "java": ``, + "javascript": ``, + "json": ``, + "kotlin": ``, + "less": ``, + "lock": ``, + "lua": ``, + "markdown": ``, + "nix": ``, + "ocaml": ``, + "php": ``, + "python": ``, + "r": ``, + "react": ``, + "react_ts": ``, + "ruby": ``, + "rust": ``, + "sass": ``, + "scala": ``, + "settings": ``, + "svelte": ``, + "svg": ``, + "swift": ``, + "terraform": ``, + "toml": ``, + "typescript": ``, + "vue": ``, + "webassembly": ``, + "xml": ``, + "yaml": ``, + "zig": ``, +}; + +const EXTENSION_TO_ICON: Record = { + "astro": "astro", + "bash": "console", + "c": "c", + "cfg": "settings", + "clj": "clojure", + "conf": "settings", + "cpp": "cpp", + "cs": "csharp", + "css": "css", + "dart": "dart", + "erl": "erlang", + "ex": "elixir", + "exs": "elixir", + "gif": "image", + "go": "go", + "gql": "graphql", + "gradle": "gradle", + "graphql": "graphql", + "groovy": "groovy", + "h": "h", + "hcl": "hcl", + "hpp": "hpp", + "hs": "haskell", + "html": "html", + "ico": "image", + "ini": "settings", + "java": "java", + "jpeg": "image", + "jpg": "image", + "js": "javascript", + "json": "json", + "jsx": "react", + "kt": "kotlin", + "less": "less", + "lock": "lock", + "lua": "lua", + "markdown": "markdown", + "md": "markdown", + "ml": "ocaml", + "nix": "nix", + "php": "php", + "png": "image", + "py": "python", + "r": "r", + "rb": "ruby", + "rs": "rust", + "scala": "scala", + "scss": "sass", + "sh": "console", + "sql": "database", + "svelte": "svelte", + "svg": "svg", + "swift": "swift", + "tf": "terraform", + "toml": "toml", + "ts": "typescript", + "tsx": "react_ts", + "txt": "document", + "vue": "vue", + "wasm": "webassembly", + "webp": "image", + "xml": "xml", + "yaml": "yaml", + "yml": "yaml", + "zig": "zig", +}; + +export function getFileIconSvg(fileName: string): string { + const ext = getExtension(fileName); + if (ext) { + const iconName = EXTENSION_TO_ICON[ext]; + if (iconName && SVG_ICONS[iconName]) return SVG_ICONS[iconName]; + } + return SVG_ICONS["_default"]; +} + +function getExtension(name: string): string | null { + const idx = name.lastIndexOf("."); + if (idx === -1 || idx === name.length - 1) return null; + return name.slice(idx + 1).toLowerCase(); +}