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();
+}