Skip to content

Commit f033a71

Browse files
committed
feat: add native file explorer integrations
- Introduced IPC handlers for opening model directories and paths in the file explorer, enhancing user experience by allowing direct access to Hugging Face and Ollama model directories. - Updated the Electron README to document the new native integrations. - Enhanced the application menu to include a Log Viewer option. - Refactored file explorer utility functions to improve error handling and notifications. - Added tests for the new file explorer functionalities to ensure reliability.
1 parent a3e5a52 commit f033a71

File tree

13 files changed

+476
-156
lines changed

13 files changed

+476
-156
lines changed

electron/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ Key folders:
1010
- `assets/` – icons and other static resources used by Electron
1111
- `resources/` – templates and additional files bundled into the app
1212

13+
## Native Integrations
14+
15+
- File explorer bridge: IPC handlers (`file-explorer-open-path`, `file-explorer-open-directory`) expose safe OS paths such as the Hugging Face cache and Ollama models directories to the renderer via `window.api.openModelDirectory` / `openModelPath`.
16+
1317
This app is built with React and Vite. In development you can launch the UI with
1418
hot reload using:
1519

electron/src/fileExplorer.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
import { shell } from "electron";
5+
6+
import { logMessage } from "./logger";
7+
import type { FileExplorerResult, ModelDirectory } from "./types";
8+
9+
const DEFAULT_HF_SUBDIR = path.join(".cache", "huggingface", "hub");
10+
11+
function expandUserPath(p?: string | null): string | undefined {
12+
if (!p) {
13+
return undefined;
14+
}
15+
if (p.startsWith("~")) {
16+
return path.join(os.homedir(), p.slice(1));
17+
}
18+
return p;
19+
}
20+
21+
function normalizePath(p: string): string {
22+
const expanded = expandUserPath(p) ?? p;
23+
return path.resolve(expanded);
24+
}
25+
26+
function dirExists(target?: string | null): target is string {
27+
if (!target) {
28+
return false;
29+
}
30+
try {
31+
const stats = fs.statSync(target);
32+
return stats.isDirectory();
33+
} catch {
34+
return false;
35+
}
36+
}
37+
38+
function ensureDir(target?: string | null): string | undefined {
39+
if (!target) {
40+
return undefined;
41+
}
42+
const candidate = normalizePath(target);
43+
return dirExists(candidate) ? candidate : undefined;
44+
}
45+
46+
export function getOllamaModelsDir(): string | undefined {
47+
const envOverride = expandUserPath(process.env.OLLAMA_MODELS);
48+
if (envOverride) {
49+
const resolved = normalizePath(envOverride);
50+
logMessage(
51+
`Using Ollama models directory from OLLAMA_MODELS env var: ${resolved}`,
52+
"info"
53+
);
54+
return resolved;
55+
}
56+
57+
try {
58+
let candidate: string | undefined;
59+
if (process.platform === "win32") {
60+
const base = process.env.USERPROFILE || os.homedir();
61+
candidate = ensureDir(path.join(base, ".ollama", "models"));
62+
} else if (process.platform === "darwin") {
63+
candidate = ensureDir(path.join(os.homedir(), ".ollama", "models"));
64+
} else {
65+
const userPath = ensureDir(path.join(os.homedir(), ".ollama", "models"));
66+
candidate = userPath ?? ensureDir("/usr/share/ollama/.ollama/models");
67+
}
68+
69+
if (candidate) {
70+
return candidate;
71+
}
72+
} catch (error) {
73+
logMessage(
74+
`Error determining Ollama models directory: ${String(error)}`,
75+
"error"
76+
);
77+
}
78+
return undefined;
79+
}
80+
81+
export function getHuggingFaceCacheDir(): string | undefined {
82+
const candidates: (string | undefined)[] = [];
83+
const envOverride =
84+
process.env.HF_HUB_CACHE ??
85+
process.env.HUGGINGFACE_HUB_CACHE ??
86+
undefined;
87+
if (envOverride) {
88+
candidates.push(envOverride);
89+
}
90+
91+
if (process.env.HF_HOME) {
92+
candidates.push(path.join(process.env.HF_HOME, "hub"));
93+
}
94+
95+
if (process.env.XDG_CACHE_HOME) {
96+
candidates.push(
97+
path.join(process.env.XDG_CACHE_HOME, "huggingface", "hub")
98+
);
99+
}
100+
101+
candidates.push(path.join(os.homedir(), DEFAULT_HF_SUBDIR));
102+
103+
for (const candidate of candidates) {
104+
const resolved = ensureDir(candidate);
105+
if (resolved) {
106+
return resolved;
107+
}
108+
}
109+
110+
logMessage("Hugging Face cache directory does not exist or is unavailable.", "warn");
111+
return undefined;
112+
}
113+
114+
function getValidExplorableRoots(): string[] {
115+
const safeRoots: string[] = [];
116+
const ollamaDir = getOllamaModelsDir();
117+
if (ollamaDir) {
118+
safeRoots.push(ollamaDir);
119+
}
120+
121+
const hfCache = getHuggingFaceCacheDir();
122+
if (hfCache) {
123+
safeRoots.push(hfCache);
124+
}
125+
126+
return safeRoots;
127+
}
128+
129+
function isPathWithin(target: string, root: string): boolean {
130+
const relative = path.relative(root, target);
131+
return (
132+
relative === "" ||
133+
(!relative.startsWith("..") && !path.isAbsolute(relative))
134+
);
135+
}
136+
137+
export async function openPathInExplorer(
138+
requestedPath: string
139+
): Promise<FileExplorerResult> {
140+
const safeRoots = getValidExplorableRoots();
141+
if (safeRoots.length === 0) {
142+
return {
143+
status: "error",
144+
message:
145+
"Cannot open path: No safe directories (like Ollama or Hugging Face cache) could be determined.",
146+
};
147+
}
148+
149+
let normalized: string;
150+
try {
151+
normalized = normalizePath(requestedPath);
152+
} catch (error) {
153+
logMessage(
154+
`Failed to normalize path ${requestedPath}: ${String(error)}`,
155+
"warn"
156+
);
157+
return {
158+
status: "error",
159+
message: "Path could not be resolved on this system.",
160+
};
161+
}
162+
163+
const isSafe = safeRoots.some((root) => isPathWithin(normalized, root));
164+
if (!isSafe) {
165+
logMessage(
166+
`Path traversal attempt: ${normalized} is outside allowed directories ${safeRoots.join(
167+
", "
168+
)}`,
169+
"warn"
170+
);
171+
return {
172+
status: "error",
173+
message: "Access denied: Path is outside the allowed directories.",
174+
};
175+
}
176+
177+
try {
178+
const result = await shell.openPath(normalized);
179+
if (result) {
180+
throw new Error(result);
181+
}
182+
return { status: "success", path: normalized };
183+
} catch (error) {
184+
logMessage(
185+
`Failed to open path ${normalized} in explorer: ${String(error)}`,
186+
"error"
187+
);
188+
return {
189+
status: "error",
190+
message:
191+
"An internal error occurred while attempting to open the path. Please check logs for details.",
192+
};
193+
}
194+
}
195+
196+
export async function openModelDirectory(
197+
target: ModelDirectory
198+
): Promise<FileExplorerResult> {
199+
const dir =
200+
target === "ollama" ? getOllamaModelsDir() : getHuggingFaceCacheDir();
201+
202+
if (!dir) {
203+
const label =
204+
target === "ollama" ? "Ollama models" : "Hugging Face cache";
205+
return {
206+
status: "error",
207+
message: `${label} directory is not available on this system.`,
208+
};
209+
}
210+
211+
return openPathInExplorer(dir);
212+
}

electron/src/ipc.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
validateRepoId,
2929
searchNodes,
3030
} from "./packageManager";
31+
import { openModelDirectory, openPathInExplorer } from "./fileExplorer";
3132

3233
/**
3334
* This module handles Inter-Process Communication (IPC) between the Electron main process
@@ -122,6 +123,20 @@ export function initializeIpcHandlers(): void {
122123
showItemInFolder(fullPath);
123124
}
124125
);
126+
127+
createIpcMainHandler(
128+
IpcChannels.FILE_EXPLORER_OPEN_PATH,
129+
async (_event, request) => {
130+
return openPathInExplorer(request.path);
131+
}
132+
);
133+
134+
createIpcMainHandler(
135+
IpcChannels.FILE_EXPLORER_OPEN_DIRECTORY,
136+
async (_event, target) => {
137+
return openModelDirectory(target);
138+
}
139+
);
125140
// Continue to app handler
126141
createIpcMainHandler(IpcChannels.START_SERVER, async () => {
127142
logMessage("User continued to app from package manager");

electron/src/menu.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Menu, shell } from "electron";
22
import { IpcChannels } from "./types.d";
33
import { getMainWindow } from "./state";
4-
import { createPackageManagerWindow } from "./window";
4+
import { createPackageManagerWindow, createLogViewerWindow } from "./window";
55

66
/**
77
* Builds the application menu
@@ -219,6 +219,10 @@ const buildMenu = () => {
219219
label: "Package Manager",
220220
click: () => createPackageManagerWindow(),
221221
},
222+
{
223+
label: "Log Viewer",
224+
click: () => createLogViewerWindow(),
225+
},
222226
],
223227
},
224228
{

electron/src/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
UpdateProgressData,
3434
UpdateInfo,
3535
Workflow,
36+
ModelDirectory,
3637
} from "./types.d";
3738

3839
/**
@@ -93,6 +94,10 @@ contextBridge.exposeInMainWorld("api", {
9394
openLogFile: () => ipcRenderer.invoke(IpcChannels.OPEN_LOG_FILE),
9495
showItemInFolder: (fullPath: string) =>
9596
ipcRenderer.invoke(IpcChannels.SHOW_ITEM_IN_FOLDER, fullPath),
97+
openModelDirectory: (target: ModelDirectory) =>
98+
ipcRenderer.invoke(IpcChannels.FILE_EXPLORER_OPEN_DIRECTORY, target),
99+
openModelPath: (path: string) =>
100+
ipcRenderer.invoke(IpcChannels.FILE_EXPLORER_OPEN_PATH, { path }),
96101
installToLocation: (location: string, packages: PythonPackages) =>
97102
ipcRenderer.invoke(IpcChannels.INSTALL_TO_LOCATION, { location, packages }),
98103
selectCustomInstallLocation: () =>

electron/src/types.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ declare global {
88
openLogFile: () => Promise<void>;
99
showItemInFolder: (fullPath: string) => Promise<void>;
1010
openExternal: (url: string) => void;
11+
openModelDirectory: (
12+
target: ModelDirectory
13+
) => Promise<FileExplorerResult>;
14+
openModelPath: (path: string) => Promise<FileExplorerResult>;
1115
onUpdateProgress: (
1216
callback: (data: {
1317
componentName: string;
@@ -185,11 +189,21 @@ export interface MenuEventData {
185189
| "fitView";
186190
}
187191

192+
export type ModelDirectory = "huggingface" | "ollama";
193+
194+
export interface FileExplorerResult {
195+
status: "success" | "error";
196+
path?: string;
197+
message?: string;
198+
}
199+
188200
// IPC Channel names as const enum for type safety
189201
export enum IpcChannels {
190202
GET_SERVER_STATE = "get-server-state",
191203
OPEN_LOG_FILE = "open-log-file",
192204
SHOW_ITEM_IN_FOLDER = "show-item-in-folder",
205+
FILE_EXPLORER_OPEN_PATH = "file-explorer-open-path",
206+
FILE_EXPLORER_OPEN_DIRECTORY = "file-explorer-open-directory",
193207
INSTALL_TO_LOCATION = "install-to-location",
194208
SELECT_CUSTOM_LOCATION = "select-custom-location",
195209
START_SERVER = "start-server",
@@ -231,11 +245,17 @@ export interface InstallToLocationData {
231245
packages: PythonPackages;
232246
}
233247

248+
export interface FileExplorerPathRequest {
249+
path: string;
250+
}
251+
234252
// Request/Response types for each IPC channel
235253
export interface IpcRequest {
236254
[IpcChannels.GET_SERVER_STATE]: void;
237255
[IpcChannels.OPEN_LOG_FILE]: void;
238256
[IpcChannels.SHOW_ITEM_IN_FOLDER]: string; // full path
257+
[IpcChannels.FILE_EXPLORER_OPEN_PATH]: FileExplorerPathRequest;
258+
[IpcChannels.FILE_EXPLORER_OPEN_DIRECTORY]: ModelDirectory;
239259
[IpcChannels.INSTALL_TO_LOCATION]: InstallToLocationData;
240260
[IpcChannels.SELECT_CUSTOM_LOCATION]: void;
241261
[IpcChannels.START_SERVER]: void;
@@ -267,6 +287,8 @@ export interface IpcResponse {
267287
[IpcChannels.GET_SERVER_STATE]: ServerState;
268288
[IpcChannels.OPEN_LOG_FILE]: void;
269289
[IpcChannels.SHOW_ITEM_IN_FOLDER]: void;
290+
[IpcChannels.FILE_EXPLORER_OPEN_PATH]: FileExplorerResult;
291+
[IpcChannels.FILE_EXPLORER_OPEN_DIRECTORY]: FileExplorerResult;
270292
[IpcChannels.INSTALL_TO_LOCATION]: void;
271293
[IpcChannels.SELECT_CUSTOM_LOCATION]: string | null;
272294
[IpcChannels.START_SERVER]: void;

0 commit comments

Comments
 (0)