Skip to content

Commit b22bdce

Browse files
committed
feat: add workspace reset and file hygiene
1 parent 2485390 commit b22bdce

3 files changed

Lines changed: 298 additions & 2 deletions

File tree

client/src/views/FilesManager.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { useState, useEffect, useRef } from "react";
1+
import React, { useState, useEffect, useRef, useContext } from "react";
22
import {
33
Button,
4+
Dropdown,
45
Input,
56
Modal,
67
message,
@@ -21,10 +22,13 @@ import {
2122
UploadOutlined,
2223
EyeOutlined,
2324
LayoutOutlined,
25+
MoreOutlined,
26+
DeleteOutlined,
2427
} from "@ant-design/icons";
2528
import { apiClient } from "../api";
2629
import FileTreeSidebar from "../components/FileTreeSidebar";
2730
import { openLocalFile, revealInFinder } from "../electronApi";
31+
import { AppContext } from "../contexts/GlobalContext";
2832

2933
const HIDDEN_SYSTEM_FILES = new Set([
3034
"workflow_preference.json",
@@ -77,6 +81,7 @@ const transformFiles = (fileList) => {
7781
};
7882

7983
function FilesManager() {
84+
const context = useContext(AppContext);
8085
const [folders, setFolders] = useState([]);
8186
const [files, setFiles] = useState({});
8287
const [currentFolder, setCurrentFolder] = useState("root");
@@ -1202,6 +1207,79 @@ function FilesManager() {
12021207
}
12031208
};
12041209

1210+
const handleResetWorkspace = () => {
1211+
Modal.confirm({
1212+
title: "Reset workspace?",
1213+
content:
1214+
"This clears indexed mounted projects and uploaded app files for the current workspace. Source project directories on disk are preserved.",
1215+
okText: "Continue",
1216+
okButtonProps: { danger: true },
1217+
cancelText: "Cancel",
1218+
onOk: async () => {
1219+
let confirmText = "";
1220+
let finalConfirmModal = null;
1221+
1222+
finalConfirmModal = Modal.confirm({
1223+
title: "Final confirmation required",
1224+
content: (
1225+
<div>
1226+
<div style={{ marginBottom: 8 }}>
1227+
Type <strong>RESET</strong> to clear this workspace.
1228+
</div>
1229+
<Input
1230+
placeholder='Type "RESET"'
1231+
onChange={(e) => {
1232+
confirmText = e.target.value;
1233+
if (finalConfirmModal) {
1234+
finalConfirmModal.update({
1235+
okButtonProps: {
1236+
danger: true,
1237+
disabled: confirmText.trim() !== "RESET",
1238+
},
1239+
});
1240+
}
1241+
}}
1242+
/>
1243+
</div>
1244+
),
1245+
okText: "Reset Workspace",
1246+
okButtonProps: { danger: true, disabled: true },
1247+
cancelText: "Cancel",
1248+
onOk: async () => {
1249+
if (confirmText.trim() !== "RESET") {
1250+
return;
1251+
}
1252+
1253+
try {
1254+
const res = await apiClient.delete("/files/workspace", {
1255+
withCredentials: true,
1256+
});
1257+
await context.resetFileState();
1258+
await fetchFiles();
1259+
handleNavigate("root");
1260+
setSelectedItems([]);
1261+
message.success(
1262+
`Workspace reset. Removed ${res?.data?.deleted_count ?? 0} indexed item(s).`,
1263+
);
1264+
} catch (err) {
1265+
console.error("Workspace reset error", err);
1266+
message.error("Failed to reset workspace");
1267+
}
1268+
},
1269+
});
1270+
},
1271+
});
1272+
};
1273+
1274+
const workspaceMenuItems = [
1275+
{
1276+
key: "reset_workspace",
1277+
label: "Reset Workspace",
1278+
icon: <DeleteOutlined />,
1279+
danger: true,
1280+
},
1281+
];
1282+
12051283
const handleUnmountProject = async (folderKey) => {
12061284
const mountedFolder = folders.find((f) => f.key === folderKey);
12071285
if (!mountedFolder) return;
@@ -1438,6 +1516,19 @@ function FilesManager() {
14381516
>
14391517
Mount Project
14401518
</Button>
1519+
<Dropdown
1520+
menu={{
1521+
items: workspaceMenuItems,
1522+
onClick: ({ key }) => {
1523+
if (key === "reset_workspace") {
1524+
handleResetWorkspace();
1525+
}
1526+
},
1527+
}}
1528+
trigger={["click"]}
1529+
>
1530+
<Button icon={<MoreOutlined />} title="More file actions" />
1531+
</Dropdown>
14411532
</div>
14421533

14431534
{/* Content Area */}

server_api/auth/router.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
router = APIRouter()
2323

2424
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
25+
IGNORED_SYSTEM_FILENAMES = {".ds_store", "thumbs.db"}
2526

2627

2728
def _format_size(size_bytes: int) -> str:
@@ -34,6 +35,10 @@ def _format_size(size_bytes: int) -> str:
3435
return f"{size_bytes / (1024 * 1024 * 1024):.1f}GB"
3536

3637

38+
def _is_ignored_system_file(name: Optional[str]) -> bool:
39+
return str(name or "").strip().lower() in IGNORED_SYSTEM_FILENAMES
40+
41+
3742
def _ensure_unique_name(
3843
db: Session, user_id: int, parent_path: str, base_name: str
3944
) -> str:
@@ -183,7 +188,9 @@ def get_files(
183188
current_user: models.User = Depends(get_current_user),
184189
db: Session = Depends(database.get_db),
185190
):
186-
return current_user.files
191+
return [
192+
file for file in current_user.files if not _is_ignored_system_file(file.name)
193+
]
187194

188195

189196
@router.get("/files/preview/{file_id}")
@@ -272,6 +279,11 @@ def upload_file(
272279
current_user: models.User = Depends(get_current_user),
273280
db: Session = Depends(database.get_db),
274281
):
282+
if _is_ignored_system_file(file.filename):
283+
raise HTTPException(
284+
status_code=400, detail="System metadata files are ignored"
285+
)
286+
275287
# Create uploads directory if not exists
276288
upload_dir = f"uploads/{current_user.id}"
277289
os.makedirs(upload_dir, exist_ok=True)
@@ -461,6 +473,8 @@ def mount_directory(
461473
mounted_folders += 1
462474

463475
for filename in filenames:
476+
if _is_ignored_system_file(filename):
477+
continue
464478
abs_file = os.path.join(current_dir, filename)
465479
if not os.path.isfile(abs_file):
466480
continue
@@ -549,6 +563,49 @@ def unmount_project(
549563
return {"message": "Project unmounted"}
550564

551565

566+
@router.delete("/files/workspace")
567+
def reset_workspace(
568+
current_user: models.User = Depends(get_current_user),
569+
db: Session = Depends(database.get_db),
570+
):
571+
root_nodes = (
572+
db.query(models.File)
573+
.filter(models.File.user_id == current_user.id, models.File.path == "root")
574+
.all()
575+
)
576+
total_rows = (
577+
db.query(models.File).filter(models.File.user_id == current_user.id).count()
578+
)
579+
mounted_root_count = 0
580+
581+
for node in root_nodes:
582+
delete_disk_files = True
583+
if node.physical_path and not _is_managed_upload_path(
584+
current_user.id, node.physical_path
585+
):
586+
mounted_root_count += 1
587+
delete_disk_files = False
588+
589+
_delete_file_tree(
590+
db,
591+
current_user.id,
592+
node,
593+
delete_disk_files=delete_disk_files,
594+
)
595+
596+
uploads_root = os.path.abspath(os.path.join("uploads", str(current_user.id)))
597+
if os.path.isdir(uploads_root):
598+
shutil.rmtree(uploads_root, ignore_errors=True)
599+
os.makedirs(uploads_root, exist_ok=True)
600+
601+
db.commit()
602+
return {
603+
"message": "Workspace reset",
604+
"deleted_count": total_rows,
605+
"mounted_root_count": mounted_root_count,
606+
}
607+
608+
552609
@router.delete("/files/{file_id}")
553610
def delete_file(
554611
file_id: int,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import pathlib
2+
import shutil
3+
import tempfile
4+
import unittest
5+
6+
from fastapi.testclient import TestClient
7+
from sqlalchemy import create_engine
8+
from sqlalchemy.orm import sessionmaker
9+
10+
from server_api.auth import database as auth_database
11+
from server_api.auth import models
12+
from server_api.main import app as server_api_app
13+
14+
15+
class FileWorkspaceRouteTests(unittest.TestCase):
16+
def setUp(self):
17+
self.temp_dir = tempfile.TemporaryDirectory()
18+
self.db_path = pathlib.Path(self.temp_dir.name) / "auth-test.db"
19+
self.engine = create_engine(
20+
f"sqlite:///{self.db_path}", connect_args={"check_same_thread": False}
21+
)
22+
self.SessionLocal = sessionmaker(
23+
autocommit=False, autoflush=False, bind=self.engine
24+
)
25+
models.Base.metadata.create_all(bind=self.engine)
26+
27+
def override_get_db():
28+
db = self.SessionLocal()
29+
try:
30+
yield db
31+
finally:
32+
db.close()
33+
34+
server_api_app.dependency_overrides[auth_database.get_db] = override_get_db
35+
self.client = TestClient(server_api_app)
36+
37+
with self.SessionLocal() as db:
38+
guest = models.User(
39+
username="guest",
40+
email=None,
41+
hashed_password="guest",
42+
)
43+
db.add(guest)
44+
db.commit()
45+
db.refresh(guest)
46+
self.user_id = guest.id
47+
48+
self.uploads_root = pathlib.Path("uploads") / str(self.user_id)
49+
if self.uploads_root.exists():
50+
shutil.rmtree(self.uploads_root)
51+
self.uploads_root.mkdir(parents=True, exist_ok=True)
52+
53+
def tearDown(self):
54+
server_api_app.dependency_overrides.clear()
55+
if self.uploads_root.exists():
56+
shutil.rmtree(self.uploads_root, ignore_errors=True)
57+
self.engine.dispose()
58+
self.temp_dir.cleanup()
59+
60+
def _create_file(
61+
self,
62+
*,
63+
name,
64+
path="root",
65+
is_folder=False,
66+
physical_path=None,
67+
size="1B",
68+
file_type="text/plain",
69+
):
70+
with self.SessionLocal() as db:
71+
node = models.File(
72+
user_id=self.user_id,
73+
name=name,
74+
path=path,
75+
is_folder=is_folder,
76+
physical_path=physical_path,
77+
size=size,
78+
type=file_type,
79+
)
80+
db.add(node)
81+
db.commit()
82+
db.refresh(node)
83+
return node.id
84+
85+
def test_mount_directory_skips_os_metadata_files(self):
86+
mount_root = pathlib.Path(self.temp_dir.name) / "project"
87+
mount_root.mkdir()
88+
(mount_root / ".DS_Store").write_text("junk", encoding="utf-8")
89+
(mount_root / "volume.tif").write_text("data", encoding="utf-8")
90+
91+
response = self.client.post(
92+
"/files/mount",
93+
json={
94+
"directory_path": str(mount_root),
95+
"destination_path": "root",
96+
},
97+
)
98+
99+
self.assertEqual(response.status_code, 200)
100+
self.assertEqual(response.json()["mounted_files"], 1)
101+
102+
files_response = self.client.get("/files")
103+
self.assertEqual(files_response.status_code, 200)
104+
names = {item["name"] for item in files_response.json()}
105+
self.assertIn("project", names)
106+
self.assertIn("volume.tif", names)
107+
self.assertNotIn(".DS_Store", names)
108+
109+
def test_reset_workspace_preserves_mounted_sources_and_clears_uploads(self):
110+
mount_root = pathlib.Path(self.temp_dir.name) / "mounted-project"
111+
mount_root.mkdir()
112+
mounted_file = mount_root / "volume.tif"
113+
mounted_file.write_text("data", encoding="utf-8")
114+
115+
mount_response = self.client.post(
116+
"/files/mount",
117+
json={
118+
"directory_path": str(mount_root),
119+
"destination_path": "root",
120+
},
121+
)
122+
self.assertEqual(mount_response.status_code, 200)
123+
124+
upload_file = self.uploads_root / "uploaded.tif"
125+
upload_file.write_text("upload", encoding="utf-8")
126+
self._create_file(
127+
name="uploaded.tif",
128+
physical_path=str(upload_file),
129+
size="6B",
130+
)
131+
132+
response = self.client.delete("/files/workspace")
133+
134+
self.assertEqual(response.status_code, 200)
135+
self.assertGreaterEqual(response.json()["deleted_count"], 3)
136+
self.assertEqual(response.json()["mounted_root_count"], 1)
137+
self.assertTrue(mount_root.exists())
138+
self.assertTrue(mounted_file.exists())
139+
self.assertFalse(upload_file.exists())
140+
self.assertTrue(self.uploads_root.exists())
141+
142+
files_response = self.client.get("/files")
143+
self.assertEqual(files_response.status_code, 200)
144+
self.assertEqual(files_response.json(), [])
145+
146+
147+
if __name__ == "__main__":
148+
unittest.main()

0 commit comments

Comments
 (0)