Skip to content

Commit 3610c04

Browse files
committed
feat: add mounted file previews
1 parent d0605e6 commit 3610c04

7 files changed

Lines changed: 694 additions & 321 deletions

File tree

client/src/components/FilePickerModal.js

Lines changed: 120 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
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(["workflow_preference.json"]);
12+
const IMAGE_EXTENSIONS = new Set([
13+
".png",
14+
".jpg",
15+
".jpeg",
16+
".gif",
17+
".bmp",
18+
".tif",
19+
".tiff",
20+
".webp",
21+
]);
22+
623
const FilePickerModal = ({
724
visible,
825
onCancel,
@@ -13,47 +30,16 @@ const FilePickerModal = ({
1330
const [currentPath, setCurrentPath] = useState("root");
1431
const [items, setItems] = useState([]);
1532
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-
};
33+
const [previewStatus, setPreviewStatus] = useState({});
34+
const previewBaseUrl =
35+
apiClient.defaults.baseURL || "http://localhost:4242";
5136

5237
// Refactored fetch to get all files once
5338
const [allData, setAllData] = useState([]);
5439

5540
useEffect(() => {
5641
if (visible) {
42+
setCurrentPath("root");
5743
loadAllData();
5844
}
5945
}, [visible]);
@@ -73,6 +59,7 @@ const FilePickerModal = ({
7359
// Derive items for current view
7460
useEffect(() => {
7561
const filtered = allData.filter((f) => {
62+
if (!f.is_folder && HIDDEN_SYSTEM_FILES.has(f.name)) return false;
7663
if (currentPath === "root") return f.path === "root" || !f.path;
7764
return String(f.path) === currentPath;
7865
});
@@ -108,7 +95,7 @@ const FilePickerModal = ({
10895
break;
10996
}
11097
}
111-
parts.unshift({ id: "root", name: "Home" });
98+
parts.unshift({ id: "root", name: "Projects" });
11299
return parts;
113100
};
114101

@@ -147,6 +134,56 @@ const FilePickerModal = ({
147134
}
148135
};
149136

137+
const handleUploadFromLocal = () => {
138+
const input = document.createElement("input");
139+
input.type = "file";
140+
input.multiple = true;
141+
input.onchange = async (event) => {
142+
const selectedFiles = Array.from(event.target.files || []);
143+
if (!selectedFiles.length) return;
144+
let uploaded = 0;
145+
for (const file of selectedFiles) {
146+
const form = new FormData();
147+
form.append("file", file);
148+
form.append("path", currentPath);
149+
try {
150+
await apiClient.post("/files/upload", form, {
151+
headers: { "Content-Type": "multipart/form-data" },
152+
});
153+
uploaded += 1;
154+
} catch (error) {
155+
console.error("Failed to upload file from picker:", error);
156+
message.error(`Failed to upload ${file.name}`);
157+
}
158+
}
159+
if (uploaded > 0) {
160+
message.success(
161+
`Uploaded ${uploaded} file${uploaded > 1 ? "s" : ""} to this folder`,
162+
);
163+
await loadAllData();
164+
}
165+
};
166+
input.click();
167+
};
168+
169+
const isImageFile = (item) => {
170+
if (!item || item.is_folder) return false;
171+
if (item.type && item.type.startsWith("image/")) return true;
172+
const ext = `.${String(item.name || "").split(".").pop()}`.toLowerCase();
173+
return IMAGE_EXTENSIONS.has(ext);
174+
};
175+
176+
const getPreviewUrl = (item) =>
177+
`${previewBaseUrl}/files/preview/${item.id}`;
178+
179+
const markPreviewLoaded = (id) => {
180+
setPreviewStatus((prev) => ({ ...prev, [id]: "loaded" }));
181+
};
182+
183+
const markPreviewError = (id) => {
184+
setPreviewStatus((prev) => ({ ...prev, [id]: "error" }));
185+
};
186+
150187
return (
151188
<Modal
152189
title={title}
@@ -182,7 +219,7 @@ const FilePickerModal = ({
182219
}}
183220
>
184221
<Button
185-
icon={<ArrowUpOutlined />}
222+
icon={<ArrowLeftOutlined />}
186223
onClick={goUp}
187224
disabled={currentPath === "root"}
188225
style={{ marginRight: "12px" }}
@@ -194,6 +231,13 @@ const FilePickerModal = ({
194231
</Breadcrumb.Item>
195232
))}
196233
</Breadcrumb>
234+
<Button
235+
icon={<UploadOutlined />}
236+
onClick={handleUploadFromLocal}
237+
style={{ marginLeft: "auto" }}
238+
>
239+
Upload from Local
240+
</Button>
197241
</div>
198242

199243
<div style={{ height: "400px", overflow: "auto" }}>
@@ -263,6 +307,44 @@ const FilePickerModal = ({
263307
<FolderFilled
264308
style={{ color: "#1890ff", fontSize: "20px" }}
265309
/>
310+
) : isImageFile(item) ? (
311+
<div
312+
style={{
313+
width: 32,
314+
height: 32,
315+
borderRadius: 4,
316+
border: "1px solid #f0f0f0",
317+
display: "flex",
318+
alignItems: "center",
319+
justifyContent: "center",
320+
overflow: "hidden",
321+
position: "relative",
322+
background: "#fafafa",
323+
}}
324+
>
325+
{previewStatus[item.id] !== "loaded" && (
326+
<Spin size="small" />
327+
)}
328+
{previewStatus[item.id] !== "error" && (
329+
<img
330+
src={getPreviewUrl(item)}
331+
alt={item.name}
332+
loading="lazy"
333+
onLoad={() => markPreviewLoaded(item.id)}
334+
onError={() => markPreviewError(item.id)}
335+
style={{
336+
position: "absolute",
337+
inset: 0,
338+
width: "100%",
339+
height: "100%",
340+
objectFit: "cover",
341+
opacity:
342+
previewStatus[item.id] === "loaded" ? 1 : 0,
343+
transition: "opacity 0.2s ease",
344+
}}
345+
/>
346+
)}
347+
</div>
266348
) : (
267349
<FileOutlined style={{ fontSize: "20px" }} />
268350
)

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
};

client/src/components/UnifiedFileInput.js

Lines changed: 3 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import React, { useState } from "react";
2-
import { Input, Modal, Button, message } from "antd";
3-
import {
4-
FolderOpenOutlined,
5-
LaptopOutlined,
6-
CloudServerOutlined,
7-
} from "@ant-design/icons";
2+
import { Input, message } from "antd";
3+
import { FolderOpenOutlined } from "@ant-design/icons";
84
import FilePickerModal from "./FilePickerModal";
95

106
/**
117
* Unified File Input Component
128
* Supports:
139
* - Text input (path)
1410
* - Drag and drop (external files)
15-
* - File picker (Local Machine or Server Storage)
11+
* - File picker (mounted storage)
1612
*
1713
* @param {string} selectionType - 'file', 'directory', or 'fileOrDirectory' (default: 'file')
1814
* @param {object|string} value - The current value. Can be a string (path) or object { path, display }
@@ -26,40 +22,10 @@ const UnifiedFileInput = ({
2622
selectionType = "file",
2723
}) => {
2824
const [filePickerVisible, setFilePickerVisible] = useState(false);
29-
const [sourceSelectionVisible, setSourceSelectionVisible] = useState(false);
3025
const [isDragOver, setIsDragOver] = useState(false);
3126

3227
const handleBrowse = () => {
3328
if (disabled) return;
34-
setSourceSelectionVisible(true);
35-
};
36-
37-
const handleLocalSelection = async () => {
38-
setSourceSelectionVisible(false);
39-
try {
40-
const { ipcRenderer } = window.require("electron");
41-
const properties =
42-
selectionType === "directory"
43-
? ["openDirectory"]
44-
: selectionType === "fileOrDirectory"
45-
? ["openFile", "openDirectory"]
46-
: ["openFile"];
47-
48-
const filePath = await ipcRenderer.invoke("open-local-file", {
49-
properties,
50-
});
51-
if (filePath) {
52-
// For local files, path and display are the same
53-
onChange({ path: filePath, display: filePath });
54-
}
55-
} catch (error) {
56-
console.error("Failed to open file:", error);
57-
message.error("Failed to open file");
58-
}
59-
};
60-
61-
const handleServerSelection = () => {
62-
setSourceSelectionVisible(false);
6329
setFilePickerVisible(true);
6430
};
6531

@@ -186,49 +152,6 @@ const UnifiedFileInput = ({
186152
)}
187153
</div>
188154

189-
{/* Source Selection Modal */}
190-
<Modal
191-
title={
192-
<span>
193-
<FolderOpenOutlined style={{ marginRight: 8 }} />
194-
Select Source
195-
</span>
196-
}
197-
open={sourceSelectionVisible}
198-
onCancel={() => setSourceSelectionVisible(false)}
199-
footer={null}
200-
width={400}
201-
>
202-
<div
203-
style={{
204-
display: "flex",
205-
flexDirection: "column",
206-
gap: "12px",
207-
padding: "10px 0",
208-
}}
209-
>
210-
<p style={{ marginBottom: "16px" }}>
211-
Where would you like to select from?
212-
</p>
213-
<Button
214-
size="large"
215-
icon={<LaptopOutlined />}
216-
onClick={handleLocalSelection}
217-
block
218-
>
219-
Local Machine
220-
</Button>
221-
<Button
222-
size="large"
223-
icon={<CloudServerOutlined />}
224-
onClick={handleServerSelection}
225-
block
226-
>
227-
Server Storage
228-
</Button>
229-
</div>
230-
</Modal>
231-
232155
<FilePickerModal
233156
visible={filePickerVisible}
234157
onCancel={() => setFilePickerVisible(false)}

0 commit comments

Comments
 (0)