Skip to content

Commit 198e31b

Browse files
committed
add workspace directory feature
- Add directory field to workspace model with validation - New terminal shells use workspace directory as initial cwd for local connections - Preview blocks default to workspace directory when no file is set - Workspace editor UI with directory input and folder browse dialog - When directory changes, update all local terminal and preview blocks - Add shellQuote utility for safe shell command injection prevention - Export WAVETERM_WORKSPACE_DIR environment variable in shell sessions - Add tests for workspace directory validation and shell quoting
1 parent 31a8714 commit 198e31b

18 files changed

Lines changed: 532 additions & 612 deletions

File tree

emain/emain-ipc.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,21 @@ export function initIpcHandlers() {
258258
event.returnValue = getWaveVersion() as AboutModalDetails;
259259
});
260260

261+
electron.ipcMain.handle("show-open-folder-dialog", async () => {
262+
const ww = focusedWaveWindow;
263+
if (ww == null) {
264+
return null;
265+
}
266+
const result = await electron.dialog.showOpenDialog(ww, {
267+
title: "Select Workspace Directory",
268+
properties: ["openDirectory", "createDirectory"],
269+
});
270+
if (result.canceled || result.filePaths.length === 0) {
271+
return null;
272+
}
273+
return result.filePaths[0];
274+
});
275+
261276
electron.ipcMain.on("get-zoom-factor", (event) => {
262277
event.returnValue = event.sender.getZoomFactor();
263278
});

emain/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld("api", {
6868
openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId),
6969
setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId),
7070
doRefresh: () => ipcRenderer.send("do-refresh"),
71+
showOpenFolderDialog: () => ipcRenderer.invoke("show-open-folder-dialog"),
7172
});
7273

7374
// Custom event for "new-window"

frontend/app/store/services.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ class WorkspaceServiceType {
182182
}
183183

184184
// @returns object updates
185-
UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise<void> {
185+
UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, directory: string, applyDefaults: boolean): Promise<void> {
186186
return WOS.callBackendService("workspace", "UpdateWorkspace", Array.from(arguments))
187187
}
188188
}

frontend/app/tab/workspaceeditor.scss

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@
6161
}
6262
}
6363

64+
.directory-selector {
65+
margin-top: 15px;
66+
padding-top: 15px;
67+
border-top: 1px solid var(--modal-border-color);
68+
69+
.directory-label {
70+
display: block;
71+
font-size: 12px;
72+
color: var(--secondary-text-color);
73+
margin-bottom: 5px;
74+
}
75+
76+
.directory-input-row {
77+
display: flex;
78+
gap: 8px;
79+
align-items: center;
80+
81+
.directory-input {
82+
flex: 1;
83+
}
84+
85+
.browse-btn {
86+
flex-shrink: 0;
87+
}
88+
}
89+
}
90+
6491
.delete-ws-btn-wrapper {
6592
display: flex;
6693
align-items: center;

frontend/app/tab/workspaceeditor.tsx

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getApi } from "@/app/store/global";
12
import { fireAndForget, makeIconClass } from "@/util/util";
23
import clsx from "clsx";
34
import { memo, useEffect, useRef, useState } from "react";
@@ -13,19 +14,20 @@ interface ColorSelectorProps {
1314
className?: string;
1415
}
1516

16-
const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => {
17-
const handleColorClick = (color: string) => {
18-
onSelect(color);
19-
};
20-
17+
const ColorSelector = memo(function ColorSelector({
18+
colors,
19+
selectedColor,
20+
onSelect,
21+
className,
22+
}: ColorSelectorProps) {
2123
return (
2224
<div className={clsx("color-selector", className)}>
2325
{colors.map((color) => (
2426
<div
2527
key={color}
2628
className={clsx("color-circle", { selected: selectedColor === color })}
2729
style={{ backgroundColor: color }}
28-
onClick={() => handleColorClick(color)}
30+
onClick={() => onSelect(color)}
2931
/>
3032
))}
3133
</div>
@@ -39,11 +41,12 @@ interface IconSelectorProps {
3941
className?: string;
4042
}
4143

42-
const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSelectorProps) => {
43-
const handleIconClick = (icon: string) => {
44-
onSelect(icon);
45-
};
46-
44+
const IconSelector = memo(function IconSelector({
45+
icons,
46+
selectedIcon,
47+
onSelect,
48+
className,
49+
}: IconSelectorProps) {
4750
return (
4851
<div className={clsx("icon-selector", className)}>
4952
{icons.map((icon) => {
@@ -52,7 +55,7 @@ const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSel
5255
<i
5356
key={icon}
5457
className={clsx(iconClass, "icon-item", { selected: selectedIcon === icon })}
55-
onClick={() => handleIconClick(icon)}
58+
onClick={() => onSelect(icon)}
5659
/>
5760
);
5861
})}
@@ -64,33 +67,37 @@ interface WorkspaceEditorProps {
6467
title: string;
6568
icon: string;
6669
color: string;
70+
directory: string;
6771
focusInput: boolean;
6872
onTitleChange: (newTitle: string) => void;
6973
onColorChange: (newColor: string) => void;
7074
onIconChange: (newIcon: string) => void;
75+
onDirectoryChange: (newDirectory: string) => void;
7176
onDeleteWorkspace: () => void;
7277
}
73-
const WorkspaceEditorComponent = ({
78+
export const WorkspaceEditor = memo(function WorkspaceEditor({
7479
title,
7580
icon,
7681
color,
82+
directory,
7783
focusInput,
7884
onTitleChange,
7985
onColorChange,
8086
onIconChange,
87+
onDirectoryChange,
8188
onDeleteWorkspace,
82-
}: WorkspaceEditorProps) => {
89+
}: WorkspaceEditorProps) {
8390
const inputRef = useRef<HTMLInputElement>(null);
8491

8592
const [colors, setColors] = useState<string[]>([]);
8693
const [icons, setIcons] = useState<string[]>([]);
8794

8895
useEffect(() => {
8996
fireAndForget(async () => {
90-
const colors = await WorkspaceService.GetColors();
91-
const icons = await WorkspaceService.GetIcons();
92-
setColors(colors);
93-
setIcons(icons);
97+
const fetchedColors = await WorkspaceService.GetColors();
98+
const fetchedIcons = await WorkspaceService.GetIcons();
99+
setColors(fetchedColors);
100+
setIcons(fetchedIcons);
94101
});
95102
}, []);
96103

@@ -113,13 +120,37 @@ const WorkspaceEditorComponent = ({
113120
/>
114121
<ColorSelector selectedColor={color} colors={colors} onSelect={onColorChange} />
115122
<IconSelector selectedIcon={icon} icons={icons} onSelect={onIconChange} />
123+
<div className="directory-selector">
124+
<label className="directory-label">Directory</label>
125+
<div className="directory-input-row">
126+
<Input
127+
value={directory}
128+
onChange={onDirectoryChange}
129+
placeholder="~/projects/myworkspace"
130+
className="directory-input"
131+
/>
132+
<Button
133+
className="ghost browse-btn"
134+
onClick={async () => {
135+
try {
136+
const path = await getApi().showOpenFolderDialog();
137+
if (path) {
138+
onDirectoryChange(path);
139+
}
140+
} catch (e) {
141+
console.error("error opening folder dialog:", e);
142+
}
143+
}}
144+
>
145+
Browse
146+
</Button>
147+
</div>
148+
</div>
116149
<div className="delete-ws-btn-wrapper">
117150
<Button className="ghost red text-[12px] bold" onClick={onDeleteWorkspace}>
118151
Delete workspace
119152
</Button>
120153
</div>
121154
</div>
122155
);
123-
};
124-
125-
export const WorkspaceEditor = memo(WorkspaceEditorComponent) as typeof WorkspaceEditorComponent;
156+
});

frontend/app/tab/workspaceswitcher.tsx

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@ import {
1010
ExpandableMenuItemRightElement,
1111
} from "@/element/expandablemenu";
1212
import { Popover, PopoverButton, PopoverContent } from "@/element/popover";
13-
import { fireAndForget, makeIconClass, useAtomValueSafe } from "@/util/util";
13+
import { RpcApi } from "@/app/store/wshclientapi";
14+
import { TabRpcClient } from "@/app/store/wshrpcutil";
15+
import { fireAndForget, makeIconClass, shellQuote, stringToBase64, useAtomValueSafe } from "@/util/util";
1416
import clsx from "clsx";
15-
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai";
17+
import { Atom, atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai";
1618
import { splitAtom } from "jotai/utils";
1719
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
18-
import { CSSProperties, forwardRef, useCallback, useEffect } from "react";
20+
import { CSSProperties, forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
21+
import { debounce } from "throttle-debounce";
1922
import WorkspaceSVG from "../asset/workspace.svg";
2023
import { IconButton } from "../element/iconbutton";
21-
import { atoms, getApi } from "../store/global";
24+
import { atoms, getAllBlockComponentModels, getApi, globalStore } from "../store/global";
2225
import { WorkspaceService } from "../store/services";
2326
import { getObjectValue, makeORef } from "../store/wos";
2427
import { waveEventSubscribe } from "../store/wps";
@@ -84,7 +87,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
8487

8588
const saveWorkspace = () => {
8689
fireAndForget(async () => {
87-
await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true);
90+
await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", "", true);
8891
await updateWorkspaceList();
8992
setEditingWorkspace(activeWorkspace.oid);
9093
});
@@ -138,6 +141,60 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
138141
);
139142
});
140143

144+
interface BlockAwareViewModel extends ViewModel {
145+
blockId: string;
146+
blockAtom: Atom<Block>;
147+
}
148+
149+
interface PreviewViewModel extends BlockAwareViewModel {
150+
goHistory: (path: string) => Promise<void>;
151+
}
152+
153+
/**
154+
* Type guard that checks if a ViewModel has block awareness (blockId and blockAtom properties).
155+
*/
156+
function isBlockAwareViewModel(viewModel: ViewModel): viewModel is BlockAwareViewModel {
157+
return "blockId" in viewModel && "blockAtom" in viewModel;
158+
}
159+
160+
/**
161+
* Type guard that checks if a ViewModel is a preview view with navigation capabilities.
162+
*/
163+
function isPreviewViewModel(viewModel: ViewModel): viewModel is PreviewViewModel {
164+
return viewModel.viewType === "preview" && isBlockAwareViewModel(viewModel) && "goHistory" in viewModel;
165+
}
166+
167+
/**
168+
* Updates all local blocks to use a new workspace directory.
169+
* For preview blocks, navigates to the new directory.
170+
* For terminal blocks, sends a cd command to change to the new directory.
171+
* Skips blocks that have a remote connection.
172+
*/
173+
async function updateBlocksWithNewDirectory(newDirectory: string): Promise<void> {
174+
const allModels = getAllBlockComponentModels();
175+
for (const model of allModels) {
176+
if (model?.viewModel == null) {
177+
continue;
178+
}
179+
const viewModel = model.viewModel;
180+
if (!isBlockAwareViewModel(viewModel)) {
181+
continue;
182+
}
183+
const blockData = globalStore.get(viewModel.blockAtom);
184+
if (blockData?.meta?.connection) {
185+
continue;
186+
}
187+
if (isPreviewViewModel(viewModel)) {
188+
await viewModel.goHistory(newDirectory);
189+
} else if (viewModel.viewType === "term") {
190+
RpcApi.ControllerInputCommand(TabRpcClient, {
191+
blockid: viewModel.blockId,
192+
inputdata64: stringToBase64(`cd ${shellQuote(newDirectory)}\n`),
193+
});
194+
}
195+
}
196+
}
197+
141198
const WorkspaceSwitcherItem = ({
142199
entryAtom,
143200
onDeleteWorkspace,
@@ -152,20 +209,47 @@ const WorkspaceSwitcherItem = ({
152209
const workspace = workspaceEntry.workspace;
153210
const isCurrentWorkspace = activeWorkspace.oid === workspace.oid;
154211

155-
const setWorkspace = useCallback((newWorkspace: Workspace) => {
156-
setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
157-
if (newWorkspace.name != "") {
158-
fireAndForget(() =>
159-
WorkspaceService.UpdateWorkspace(
160-
workspace.oid,
161-
newWorkspace.name,
162-
newWorkspace.icon,
163-
newWorkspace.color,
164-
false
165-
)
166-
);
167-
}
168-
}, []);
212+
const pendingDirectoryRef = useRef<string | null>(null);
213+
214+
const debouncedBlockUpdate = useMemo(
215+
() =>
216+
debounce(300, (newDirectory: string) => {
217+
pendingDirectoryRef.current = null;
218+
fireAndForget(async () => {
219+
await updateBlocksWithNewDirectory(newDirectory);
220+
});
221+
}),
222+
[]
223+
);
224+
225+
const setWorkspace = useCallback(
226+
(newWorkspace: Workspace) => {
227+
setWorkspaceEntry((prev) => {
228+
const oldDirectory = prev.workspace.directory;
229+
const newDirectory = newWorkspace.directory;
230+
const directoryChanged = newDirectory !== oldDirectory;
231+
232+
if (newWorkspace.name !== "") {
233+
fireAndForget(async () => {
234+
await WorkspaceService.UpdateWorkspace(
235+
prev.workspace.oid,
236+
newWorkspace.name,
237+
newWorkspace.icon,
238+
newWorkspace.color,
239+
newWorkspace.directory ?? "",
240+
false
241+
);
242+
});
243+
if (directoryChanged && isCurrentWorkspace && newDirectory) {
244+
pendingDirectoryRef.current = newDirectory;
245+
debouncedBlockUpdate(newDirectory);
246+
}
247+
}
248+
return { ...prev, workspace: newWorkspace };
249+
});
250+
},
251+
[debouncedBlockUpdate, isCurrentWorkspace, setWorkspaceEntry]
252+
);
169253

170254
const isActive = !!workspaceEntry.windowId;
171255
const editIconDecl: IconButtonDecl = {
@@ -233,10 +317,12 @@ const WorkspaceSwitcherItem = ({
233317
title={workspace.name}
234318
icon={workspace.icon}
235319
color={workspace.color}
320+
directory={workspace.directory ?? ""}
236321
focusInput={isEditing}
237322
onTitleChange={(title) => setWorkspace({ ...workspace, name: title })}
238323
onColorChange={(color) => setWorkspace({ ...workspace, color })}
239324
onIconChange={(icon) => setWorkspace({ ...workspace, icon })}
325+
onDirectoryChange={(directory) => setWorkspace({ ...workspace, directory })}
240326
onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)}
241327
/>
242328
</ExpandableMenuItem>

0 commit comments

Comments
 (0)