Skip to content

Commit 3caf784

Browse files
authored
Merge pull request #114 from PytorchConnectomics/file-management-tab
File Management: mounted project polish and Finder integration
2 parents d0605e6 + 48cb0e1 commit 3caf784

8 files changed

Lines changed: 878 additions & 326 deletions

File tree

client/main.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
dialog,
88
Menu,
99
screen,
10+
shell,
1011
} = require("electron");
1112

1213
let mainWindow;
@@ -157,6 +158,16 @@ ipcMain.handle("open-local-file", async (_event, options = {}) => {
157158
}
158159
});
159160

161+
ipcMain.handle("reveal-in-finder", async (_event, targetPath) => {
162+
if (!targetPath) return null;
163+
try {
164+
await shell.showItemInFolder(targetPath);
165+
} catch (err) {
166+
return null;
167+
}
168+
return true;
169+
});
170+
160171
app.on("ready", createWindow);
161172

162173
app.on("window-all-closed", () => {

client/src/components/FilePickerModal.js

Lines changed: 163 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
11
import React, { useState, useEffect } from "react";
22
import { Modal, List, Breadcrumb, Button, Spin, message } from "antd";
3-
import { FolderFilled, FileOutlined, ArrowUpOutlined } from "@ant-design/icons";
3+
import {
4+
FolderFilled,
5+
FileOutlined,
6+
ArrowLeftOutlined,
7+
UploadOutlined,
8+
} from "@ant-design/icons";
49
import { apiClient } from "../api";
510

11+
const HIDDEN_SYSTEM_FILES = new Set([
12+
"workflow_preference.json",
13+
".ds_store",
14+
"thumbs.db",
15+
]);
16+
const IMAGE_EXTENSIONS = new Set([
17+
".png",
18+
".jpg",
19+
".jpeg",
20+
".gif",
21+
".bmp",
22+
".tif",
23+
".tiff",
24+
".webp",
25+
]);
26+
627
const FilePickerModal = ({
728
visible,
829
onCancel,
@@ -13,47 +34,18 @@ const FilePickerModal = ({
1334
const [currentPath, setCurrentPath] = useState("root");
1435
const [items, setItems] = useState([]);
1536
const [loading, setLoading] = useState(false);
16-
17-
useEffect(() => {
18-
if (visible) {
19-
fetchFiles();
20-
}
21-
}, [visible, currentPath]);
22-
23-
const fetchFiles = async () => {
24-
setLoading(true);
25-
try {
26-
const res = await apiClient.get("/files");
27-
const allFiles = res.data;
28-
29-
// Filter items for current path
30-
const currentItems = allFiles.filter((f) => {
31-
if (currentPath === "root") {
32-
return f.path === "root" || !f.path;
33-
}
34-
return f.path === currentPath;
35-
});
36-
37-
// Sort: Folders first, then files
38-
currentItems.sort((a, b) => {
39-
if (a.is_folder === b.is_folder) return a.name.localeCompare(b.name);
40-
return a.is_folder ? -1 : 1;
41-
});
42-
43-
setItems(currentItems);
44-
} catch (error) {
45-
console.error("Failed to load files:", error);
46-
message.error("Failed to load files");
47-
} finally {
48-
setLoading(false);
49-
}
50-
};
37+
const [previewStatus, setPreviewStatus] = useState({});
38+
const [onlyImages, setOnlyImages] = useState(false);
39+
const previewBaseUrl =
40+
apiClient.defaults.baseURL || "http://localhost:4242";
5141

5242
// Refactored fetch to get all files once
5343
const [allData, setAllData] = useState([]);
5444

5545
useEffect(() => {
5646
if (visible) {
47+
setCurrentPath("root");
48+
setOnlyImages(false);
5749
loadAllData();
5850
}
5951
}, [visible]);
@@ -73,17 +65,23 @@ const FilePickerModal = ({
7365
// Derive items for current view
7466
useEffect(() => {
7567
const filtered = allData.filter((f) => {
68+
const nameLower = String(f.name || "").toLowerCase();
69+
if (!f.is_folder && HIDDEN_SYSTEM_FILES.has(nameLower)) return false;
7670
if (currentPath === "root") return f.path === "root" || !f.path;
7771
return String(f.path) === currentPath;
7872
});
7973

80-
filtered.sort((a, b) => {
74+
const filteredByType = onlyImages
75+
? filtered.filter((f) => f.is_folder || isImageFile(f))
76+
: filtered;
77+
78+
filteredByType.sort((a, b) => {
8179
if (a.is_folder === b.is_folder) return a.name.localeCompare(b.name);
8280
return a.is_folder ? -1 : 1;
8381
});
8482

85-
setItems(filtered);
86-
}, [currentPath, allData]);
83+
setItems(filteredByType);
84+
}, [currentPath, allData, onlyImages]);
8785

8886
const getParentPath = () => {
8987
if (currentPath === "root") return null;
@@ -108,7 +106,7 @@ const FilePickerModal = ({
108106
break;
109107
}
110108
}
111-
parts.unshift({ id: "root", name: "Home" });
109+
parts.unshift({ id: "root", name: "Projects" });
112110
return parts;
113111
};
114112

@@ -147,6 +145,56 @@ const FilePickerModal = ({
147145
}
148146
};
149147

148+
const handleUploadFromLocal = () => {
149+
const input = document.createElement("input");
150+
input.type = "file";
151+
input.multiple = true;
152+
input.onchange = async (event) => {
153+
const selectedFiles = Array.from(event.target.files || []);
154+
if (!selectedFiles.length) return;
155+
let uploaded = 0;
156+
for (const file of selectedFiles) {
157+
const form = new FormData();
158+
form.append("file", file);
159+
form.append("path", currentPath);
160+
try {
161+
await apiClient.post("/files/upload", form, {
162+
headers: { "Content-Type": "multipart/form-data" },
163+
});
164+
uploaded += 1;
165+
} catch (error) {
166+
console.error("Failed to upload file from picker:", error);
167+
message.error(`Failed to upload ${file.name}`);
168+
}
169+
}
170+
if (uploaded > 0) {
171+
message.success(
172+
`Uploaded ${uploaded} file${uploaded > 1 ? "s" : ""} to this folder`,
173+
);
174+
await loadAllData();
175+
}
176+
};
177+
input.click();
178+
};
179+
180+
const isImageFile = (item) => {
181+
if (!item || item.is_folder) return false;
182+
if (item.type && item.type.startsWith("image/")) return true;
183+
const ext = `.${String(item.name || "").split(".").pop()}`.toLowerCase();
184+
return IMAGE_EXTENSIONS.has(ext);
185+
};
186+
187+
const getPreviewUrl = (item) =>
188+
`${previewBaseUrl}/files/preview/${item.id}`;
189+
190+
const markPreviewLoaded = (id) => {
191+
setPreviewStatus((prev) => ({ ...prev, [id]: "loaded" }));
192+
};
193+
194+
const markPreviewError = (id) => {
195+
setPreviewStatus((prev) => ({ ...prev, [id]: "error" }));
196+
};
197+
150198
return (
151199
<Modal
152200
title={title}
@@ -182,7 +230,7 @@ const FilePickerModal = ({
182230
}}
183231
>
184232
<Button
185-
icon={<ArrowUpOutlined />}
233+
icon={<ArrowLeftOutlined />}
186234
onClick={goUp}
187235
disabled={currentPath === "root"}
188236
style={{ marginRight: "12px" }}
@@ -194,6 +242,20 @@ const FilePickerModal = ({
194242
</Breadcrumb.Item>
195243
))}
196244
</Breadcrumb>
245+
<Button
246+
icon={<UploadOutlined />}
247+
onClick={handleUploadFromLocal}
248+
style={{ marginLeft: "auto" }}
249+
>
250+
Upload from Local
251+
</Button>
252+
<Button
253+
type={onlyImages ? "primary" : "default"}
254+
onClick={() => setOnlyImages((prev) => !prev)}
255+
style={{ marginLeft: 8 }}
256+
>
257+
Images Only
258+
</Button>
197259
</div>
198260

199261
<div style={{ height: "400px", overflow: "auto" }}>
@@ -263,11 +325,70 @@ const FilePickerModal = ({
263325
<FolderFilled
264326
style={{ color: "#1890ff", fontSize: "20px" }}
265327
/>
328+
) : isImageFile(item) ? (
329+
<div
330+
style={{
331+
width: 32,
332+
height: 32,
333+
borderRadius: 4,
334+
border: "1px solid #f0f0f0",
335+
display: "flex",
336+
alignItems: "center",
337+
justifyContent: "center",
338+
overflow: "hidden",
339+
position: "relative",
340+
background: "#fafafa",
341+
}}
342+
>
343+
{previewStatus[item.id] !== "loaded" && (
344+
<Spin size="small" />
345+
)}
346+
{previewStatus[item.id] !== "error" && (
347+
<img
348+
src={getPreviewUrl(item)}
349+
alt={item.name}
350+
loading="lazy"
351+
onLoad={() => markPreviewLoaded(item.id)}
352+
onError={() => markPreviewError(item.id)}
353+
style={{
354+
position: "absolute",
355+
inset: 0,
356+
width: "100%",
357+
height: "100%",
358+
objectFit: "cover",
359+
opacity:
360+
previewStatus[item.id] === "loaded" ? 1 : 0,
361+
transition: "opacity 0.2s ease",
362+
}}
363+
/>
364+
)}
365+
</div>
266366
) : (
267367
<FileOutlined style={{ fontSize: "20px" }} />
268368
)
269369
}
270-
title={item.name}
370+
title={
371+
<span>
372+
{item.name}
373+
{item.is_folder &&
374+
(item.path === "root" || !item.path) &&
375+
item.physical_path && (
376+
<span
377+
style={{
378+
marginLeft: 8,
379+
padding: "2px 6px",
380+
fontSize: 10,
381+
borderRadius: 10,
382+
background: "#f0f5ff",
383+
color: "#2f54eb",
384+
border: "1px solid #adc6ff",
385+
}}
386+
>
387+
Mounted
388+
</span>
389+
)}
390+
</span>
391+
}
271392
description={item.size ? item.size : null}
272393
/>
273394
</List.Item>

client/src/components/FileTreeSidebar.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,55 @@ const FileTreeSidebar = ({
115115
onSelect={onSelectHandler}
116116
treeData={treeData}
117117
expandAction="click"
118-
style={{ backgroundColor: "transparent" }}
118+
style={{ backgroundColor: "transparent", fontSize: 13 }}
119+
titleRender={(nodeData) => (
120+
<span
121+
style={{
122+
display: "inline-block",
123+
maxWidth: `calc(${width}px - 92px)`,
124+
overflow: "hidden",
125+
textOverflow: "ellipsis",
126+
whiteSpace: "nowrap",
127+
verticalAlign: "middle",
128+
lineHeight: "22px",
129+
}}
130+
title={String(nodeData.title)}
131+
>
132+
{nodeData.title}
133+
</span>
134+
)}
119135
draggable
120136
blockNode
121137
onDrop={handleDrop}
122138
onRightClick={handleRightClick}
123139
/>
140+
<style>
141+
{`
142+
.ant-tree .ant-tree-treenode {
143+
min-height: 28px;
144+
padding: 0;
145+
}
146+
.ant-tree .ant-tree-node-content-wrapper {
147+
min-height: 28px;
148+
padding: 2px 6px;
149+
display: flex;
150+
align-items: center;
151+
gap: 6px;
152+
}
153+
.ant-tree .ant-tree-iconEle {
154+
margin-right: 2px;
155+
}
156+
.ant-tree .ant-tree-switcher {
157+
width: 16px;
158+
}
159+
.ant-tree .ant-tree-indent-unit {
160+
width: 14px;
161+
}
162+
.ant-tree .ant-tree-list-holder-inner {
163+
gap: 0;
164+
}
165+
`}
166+
</style>
124167
</div>
125168
);
126169
};

0 commit comments

Comments
 (0)