From aa25ebb87356c91d560f7e53470a27f647a7e5fb Mon Sep 17 00:00:00 2001 From: lyx-tec Date: Fri, 26 Jun 2026 22:54:45 +0800 Subject: [PATCH 1/4] feat: add default connection and CWD settings to workspaces --- frontend/app/store/keymodel.ts | 12 +++ frontend/app/store/services.ts | 7 +- frontend/app/tab/workspaceeditor.scss | 93 +++++++++++++++--- frontend/app/tab/workspaceeditor.tsx | 95 +++++++++++++++++-- frontend/app/tab/workspaceswitcher.tsx | 36 ++++--- frontend/types/gotypes.d.ts | 2 + .../workspaceservice/workspaceservice.go | 27 +++++- pkg/waveobj/wtype.go | 18 ++-- pkg/wcore/workspace.go | 50 +++++++++- 9 files changed, 295 insertions(+), 45 deletions(-) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 02ced3d3ef..4d6fed9189 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -361,18 +361,30 @@ function getDefaultNewBlockDef(): BlockDef { }; const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); + let hasExplicitConn = false; + let hasExplicitCwd = false; if (focusedNode != null) { const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId)); const blockData = globalStore.get(blockAtom); if (blockData?.meta?.view == "term") { if (blockData?.meta?.["cmd:cwd"] != null) { termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"]; + hasExplicitCwd = true; } } if (blockData?.meta?.connection != null) { termBlockDef.meta.connection = blockData.meta.connection; + hasExplicitConn = true; } } + // fall back to workspace defaults when the focused block has no explicit values + const workspace = globalStore.get(atoms.workspace); + if (!hasExplicitConn && workspace?.defaultconnname) { + termBlockDef.meta.connection = workspace.defaultconnname; + } + if (!hasExplicitCwd && workspace?.defaultcwd) { + termBlockDef.meta["cmd:cwd"] = workspace.defaultcwd; + } return termBlockDef; } diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index d4898c285e..0e5356d631 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -188,6 +188,11 @@ export class WorkspaceServiceType { return callBackendService(this?.waveEnv, "workspace", "GetColors", Array.from(arguments)) } + // @returns connectionNames + GetConnectionNames(): Promise { + return callBackendService(this?.waveEnv, "workspace", "GetConnectionNames", Array.from(arguments)) + } + // @returns icons GetIcons(): Promise { return callBackendService(this?.waveEnv, "workspace", "GetIcons", Array.from(arguments)) @@ -207,7 +212,7 @@ export class WorkspaceServiceType { } // @returns object updates - UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise { + UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, defaultConnName: string, defaultCwd: string, applyDefaults: boolean): Promise { return callBackendService(this?.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments)) } } diff --git a/frontend/app/tab/workspaceeditor.scss b/frontend/app/tab/workspaceeditor.scss index d850d0a948..be03b5a7b7 100644 --- a/frontend/app/tab/workspaceeditor.scss +++ b/frontend/app/tab/workspaceeditor.scss @@ -1,13 +1,80 @@ .workspace-editor { width: 100%; + .input { margin: 5px 0 10px; } + .connection-selector { + position: relative; + margin: 5px 0 10px; + + .connection-input-wrapper { + position: relative; + width: 100%; + } + + .connection-input { + padding-right: 24px; + } + + .dropdown-arrow { + position: absolute; + right: 7px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + cursor: pointer; + color: var(--main-text-color); + transition: transform 0.15s ease; + + &.open { + transform: translateY(-50%) rotate(180deg); + } + } + + .connection-dropdown { + position: absolute; + z-index: 1000; + left: 0; + right: 0; + top: calc(100% + 4px); + background: var(--modal-bg-color); + border: 1px solid var(--modal-border-color); + border-radius: 6px; + box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.3); + padding: 4px; + max-height: 200px; + overflow-y: auto; + + .dropdown-item { + cursor: pointer; + padding: 5px 8px; + border-radius: 4px; + font-size: 12px; + color: var(--main-text-color); + + &:hover { + background-color: var(--highlight-bg-color); + } + + &.selected { + background-color: rgb(from var(--accent-color) r g b / 0.5); + } + } + + .dropdown-divider { + height: 1px; + background: var(--modal-border-color); + margin: 4px 0; + } + } + } + .color-selector { display: grid; - grid-template-columns: repeat(auto-fit, minmax(15px, 15px)); // Ensures each color circle has a fixed 14px size - grid-gap: 18.5px; // Space between items + grid-template-columns: repeat(auto-fit, minmax(15px, 15px)); + grid-gap: 18.5px; justify-content: center; align-items: center; margin-top: 5px; @@ -21,7 +88,6 @@ cursor: pointer; position: relative; - // Border offset outward &:before { content: ""; position: absolute; @@ -34,16 +100,23 @@ } &.selected:before { - border-color: var(--main-text-color); // Highlight for the selected circle + border-color: var(--main-text-color); } } } + .delete-ws-btn-wrapper { + display: flex; + align-items: center; + justify-content: center; + margin-top: 10px; + } + .icon-selector { display: grid; - grid-template-columns: repeat(auto-fit, minmax(16px, 16px)); // Ensures each color circle has a fixed 14px size - grid-column-gap: 17.5px; // Space between items - grid-row-gap: 13px; // Space between items + grid-template-columns: repeat(auto-fit, minmax(16px, 16px)); + grid-column-gap: 17.5px; + grid-row-gap: 13px; justify-content: center; align-items: center; margin-top: 15px; @@ -61,10 +134,4 @@ } } - .delete-ws-btn-wrapper { - display: flex; - align-items: center; - justify-content: center; - margin-top: 10px; - } } diff --git a/frontend/app/tab/workspaceeditor.tsx b/frontend/app/tab/workspaceeditor.tsx index dee2ccb6e1..f34aa1b3fa 100644 --- a/frontend/app/tab/workspaceeditor.tsx +++ b/frontend/app/tab/workspaceeditor.tsx @@ -1,6 +1,6 @@ import { fireAndForget, makeIconClass } from "@/util/util"; import clsx from "clsx"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "../element/button"; import { Input } from "../element/input"; import { WorkspaceService } from "../store/services"; @@ -60,37 +60,118 @@ const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSel ); }); +interface ConnectionSelectorProps { + connectionNames: string[]; + value: string; + onChange: (value: string) => void; +} + +const ConnectionSelector = memo(({ connectionNames, value, onChange }: ConnectionSelectorProps) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const items = useMemo(() => { + const sorted = [...connectionNames].sort(); + return [{ label: "(none)", value: "" }, { divider: true as const }, ...sorted.map((n) => ({ label: n, value: n }))]; + }, [connectionNames]); + + const handleSelect = (val: string) => { + onChange(val); + setOpen(false); + }; + + return ( +
+
+ setOpen(true)} + /> + setOpen(!open)} + /> +
+ {open && ( +
+ {items.map((item, idx) => { + if ("divider" in item) { + return
; + } + return ( +
handleSelect(item.value)} + > + {item.label} +
+ ); + })} +
+ )} +
+ ); +}); + interface WorkspaceEditorProps { title: string; icon: string; color: string; + connName: string; + cwd: string; focusInput: boolean; onTitleChange: (newTitle: string) => void; onColorChange: (newColor: string) => void; onIconChange: (newIcon: string) => void; + onConnNameChange: (newConnName: string) => void; + onCwdChange: (newCwd: string) => void; onDeleteWorkspace: () => void; } + const WorkspaceEditorComponent = ({ title, icon, color, + connName, + cwd, focusInput, onTitleChange, onColorChange, onIconChange, + onConnNameChange, + onCwdChange, onDeleteWorkspace, }: WorkspaceEditorProps) => { const inputRef = useRef(null); const [colors, setColors] = useState([]); const [icons, setIcons] = useState([]); + const [connectionNames, setConnectionNames] = useState([]); useEffect(() => { fireAndForget(async () => { - const colors = await WorkspaceService.GetColors(); - const icons = await WorkspaceService.GetIcons(); - setColors(colors); - setIcons(icons); + const [clrs, ics, conns] = await Promise.all([ + WorkspaceService.GetColors(), + WorkspaceService.GetIcons(), + WorkspaceService.GetConnectionNames(), + ]); + setColors(clrs); + setIcons(ics); + setConnectionNames(conns); }); }, []); @@ -111,6 +192,8 @@ const WorkspaceEditorComponent = ({ autoFocus autoSelect /> + +
@@ -122,4 +205,4 @@ const WorkspaceEditorComponent = ({ ); }; -export const WorkspaceEditor = memo(WorkspaceEditorComponent) as typeof WorkspaceEditorComponent; +export const WorkspaceEditor = memo(WorkspaceEditorComponent); diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 5cc17516ec..62e5ce2fc6 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -16,7 +16,7 @@ import clsx from "clsx"; import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { splitAtom } from "jotai/utils"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; -import { CSSProperties, forwardRef, useCallback, useEffect } from "react"; +import { CSSProperties, forwardRef, useCallback, useEffect, useState } from "react"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; import { globalStore } from "@/app/store/jotaiStore"; @@ -100,7 +100,12 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { const saveWorkspace = () => { fireAndForget(async () => { - await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); + await env.services.workspace.UpdateWorkspace( + activeWorkspace.oid, "", "", "", + activeWorkspace.defaultconnname ?? "", + activeWorkspace.defaultcwd ?? "", + true + ); await updateWorkspaceList(); setEditingWorkspace(activeWorkspace.oid); }); @@ -169,20 +174,23 @@ const WorkspaceSwitcherItem = ({ const workspace = workspaceEntry.workspace; const isCurrentWorkspace = activeWorkspace.oid === workspace.oid; - const setWorkspace = useCallback((newWorkspace: Workspace) => { - setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); - if (newWorkspace.name != "") { + const setWorkspaceField = useCallback((patch: Partial) => { + const updated = { ...workspace, ...patch }; + setWorkspaceEntry({ ...workspaceEntry, workspace: updated }); + if (updated.name != "") { fireAndForget(() => env.services.workspace.UpdateWorkspace( workspace.oid, - newWorkspace.name, - newWorkspace.icon, - newWorkspace.color, + updated.name, + updated.icon, + updated.color, + updated.defaultconnname ?? "", + updated.defaultcwd ?? "", false ) ); } - }, []); + }, [workspace.oid, workspaceEntry]); const isActive = !!workspaceEntry.windowId; const editIconDecl: IconButtonDecl = { @@ -250,10 +258,14 @@ const WorkspaceSwitcherItem = ({ title={workspace.name} icon={workspace.icon} color={workspace.color} + connName={workspace.defaultconnname ?? ""} + cwd={workspace.defaultcwd ?? ""} focusInput={isEditing} - onTitleChange={(title) => setWorkspace({ ...workspace, name: title })} - onColorChange={(color) => setWorkspace({ ...workspace, color })} - onIconChange={(icon) => setWorkspace({ ...workspace, icon })} + onTitleChange={(newTitle) => setWorkspaceField({ name: newTitle })} + onColorChange={(newColor) => setWorkspaceField({ color: newColor })} + onIconChange={(newIcon) => setWorkspaceField({ icon: newIcon })} + onConnNameChange={(newConnName) => setWorkspaceField({ defaultconnname: newConnName })} + onCwdChange={(newCwd) => setWorkspaceField({ defaultcwd: newCwd })} onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)} /> diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index dd7b12b9ce..5991d4b661 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -2249,6 +2249,8 @@ declare global { name?: string; icon?: string; color?: string; + defaultconnname?: string; + defaultcwd?: string; tabids: string[]; activetabid: string; }; diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index 1d7b116bdc..d57bcd5083 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -6,12 +6,14 @@ package workspaceservice import ( "context" "fmt" + "sort" "time" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" @@ -38,13 +40,13 @@ func (svc *WorkspaceService) CreateWorkspace(ctx context.Context, name string, i func (svc *WorkspaceService) UpdateWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ - ArgNames: []string{"ctx", "workspaceId", "name", "icon", "color", "applyDefaults"}, + ArgNames: []string{"ctx", "workspaceId", "name", "icon", "color", "defaultConnName", "defaultCwd", "applyDefaults"}, } } -func (svc *WorkspaceService) UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (waveobj.UpdatesRtnType, error) { +func (svc *WorkspaceService) UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, defaultConnName string, defaultCwd string, applyDefaults bool) (waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) - _, updated, err := wcore.UpdateWorkspace(ctx, workspaceId, name, icon, color, applyDefaults) + _, updated, err := wcore.UpdateWorkspace(ctx, workspaceId, name, icon, color, defaultConnName, defaultCwd, applyDefaults) if err != nil { return nil, fmt.Errorf("error updating workspace: %w", err) } @@ -146,6 +148,25 @@ func (svc *WorkspaceService) GetIcons() []string { return wcore.WorkspaceIcons[:] } +func (svc *WorkspaceService) GetConnectionNames_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ReturnDesc: "connectionNames", + } +} + +func (svc *WorkspaceService) GetConnectionNames() []string { + fullConfig := wconfig.GetWatcher().GetFullConfig() + names := make([]string, 0, len(fullConfig.Connections)+1) + names = append(names, "local") + for connName := range fullConfig.Connections { + if connName != "local" { + names = append(names, connName) + } + } + sort.Strings(names[1:]) + return names +} + func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 01323fa143..ec88719378 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -174,14 +174,16 @@ type ActiveTabUpdate struct { } type Workspace struct { - OID string `json:"oid"` - Version int `json:"version"` - Name string `json:"name,omitempty"` - Icon string `json:"icon,omitempty"` - Color string `json:"color,omitempty"` - TabIds []string `json:"tabids"` - ActiveTabId string `json:"activetabid"` - Meta MetaMapType `json:"meta"` + OID string `json:"oid"` + Version int `json:"version"` + Name string `json:"name,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + DefaultConnName string `json:"defaultconnname,omitempty"` + DefaultCwd string `json:"defaultcwd,omitempty"` + TabIds []string `json:"tabids"` + ActiveTabId string `json:"activetabid"` + Meta MetaMapType `json:"meta"` } func (*Workspace) GetOType() string { diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index c01e509a13..cb23c4d309 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -71,12 +71,12 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string Event: wps.Event_WorkspaceUpdate, }) - ws, _, err = UpdateWorkspace(ctx, ws.OID, name, icon, color, applyDefaults) + ws, _, err = UpdateWorkspace(ctx, ws.OID, name, icon, color, "", "", applyDefaults) return ws, err } // Returns updated workspace, whether it was updated, error. -func UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (*waveobj.Workspace, bool, error) { +func UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, defaultConnName string, defaultCwd string, applyDefaults bool) (*waveobj.Workspace, bool, error) { ws, err := GetWorkspace(ctx, workspaceId) updated := false if err != nil { @@ -108,6 +108,14 @@ func UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon ws.Color = WorkspaceColors[len(wsList)%len(WorkspaceColors)] updated = true } + if defaultConnName != ws.DefaultConnName { + ws.DefaultConnName = defaultConnName + updated = true + } + if defaultCwd != ws.DefaultCwd { + ws.DefaultCwd = defaultCwd + updated = true + } if updated { wstore.DBUpdate(ctx, ws) } @@ -254,6 +262,7 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate if err != nil { return tab.OID, fmt.Errorf("error applying new tab layout: %w", err) } + applyWorkspaceDefaultsToBlocks(ctx, workspaceId, tab) tabBg := getTabBackground() if tabBg != "" { tabORef := waveobj.ORefFromWaveObj(tab) @@ -290,6 +299,43 @@ func createTabObj(ctx context.Context, workspaceId string, name string, meta wav return tab, nil } +func applyWorkspaceDefaultsToBlocks(ctx context.Context, workspaceId string, tab *waveobj.Tab) { + ws, err := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) + if err != nil || ws == nil { + return + } + if ws.DefaultConnName == "" && ws.DefaultCwd == "" { + return + } + for _, blockId := range tab.BlockIds { + block, err := wstore.DBGet[*waveobj.Block](ctx, blockId) + if err != nil || block == nil { + continue + } + meta := block.Meta + if meta == nil { + meta = make(waveobj.MetaMapType) + } + updated := false + if ws.DefaultConnName != "" { + if _, exists := meta[waveobj.MetaKey_Connection]; !exists { + meta[waveobj.MetaKey_Connection] = ws.DefaultConnName + updated = true + } + } + if ws.DefaultCwd != "" { + if _, exists := meta[waveobj.MetaKey_CmdCwd]; !exists { + meta[waveobj.MetaKey_CmdCwd] = ws.DefaultCwd + updated = true + } + } + if updated { + block.Meta = meta + wstore.DBUpdate(ctx, block) + } + } +} + // Must delete all blocks individually first. // Also deletes LayoutState. // recursive: if true, will recursively close parent window, workspace, if they are empty. From 30605dc3a44fb6d2abdf71eb241241443f6c3b00 Mon Sep 17 00:00:00 2001 From: lyx-tec Date: Sat, 27 Jun 2026 09:38:11 +0800 Subject: [PATCH 2/4] Update workspace switcher editing flow --- frontend/app/tab/workspaceeditor.scss | 2 +- frontend/app/tab/workspaceeditor.tsx | 14 +- frontend/app/tab/workspaceswitcher.scss | 136 +++++++-- frontend/app/tab/workspaceswitcher.tsx | 282 ++++++++++++------ .../workspaceservice/workspaceservice.go | 22 +- 5 files changed, 340 insertions(+), 116 deletions(-) diff --git a/frontend/app/tab/workspaceeditor.scss b/frontend/app/tab/workspaceeditor.scss index be03b5a7b7..1aa9ee3f54 100644 --- a/frontend/app/tab/workspaceeditor.scss +++ b/frontend/app/tab/workspaceeditor.scss @@ -46,6 +46,7 @@ padding: 4px; max-height: 200px; overflow-y: auto; + overscroll-behavior: contain; .dropdown-item { cursor: pointer; @@ -133,5 +134,4 @@ } } } - } diff --git a/frontend/app/tab/workspaceeditor.tsx b/frontend/app/tab/workspaceeditor.tsx index f34aa1b3fa..c9306d129e 100644 --- a/frontend/app/tab/workspaceeditor.tsx +++ b/frontend/app/tab/workspaceeditor.tsx @@ -1,5 +1,6 @@ import { fireAndForget, makeIconClass } from "@/util/util"; import clsx from "clsx"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { memo, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "../element/button"; import { Input } from "../element/input"; @@ -82,7 +83,11 @@ const ConnectionSelector = memo(({ connectionNames, value, onChange }: Connectio const items = useMemo(() => { const sorted = [...connectionNames].sort(); - return [{ label: "(none)", value: "" }, { divider: true as const }, ...sorted.map((n) => ({ label: n, value: n }))]; + return [ + { label: "(none)", value: "" }, + { divider: true as const }, + ...sorted.map((n) => ({ label: n, value: n })), + ]; }, [connectionNames]); const handleSelect = (val: string) => { @@ -106,7 +111,10 @@ const ConnectionSelector = memo(({ connectionNames, value, onChange }: Connectio />
{open && ( -
+ {items.map((item, idx) => { if ("divider" in item) { return
; @@ -121,7 +129,7 @@ const ConnectionSelector = memo(({ connectionNames, value, onChange }: Connectio
); })} -
+ )}
); diff --git a/frontend/app/tab/workspaceswitcher.scss b/frontend/app/tab/workspaceswitcher.scss index b8c5041ac7..eb0cd0499c 100644 --- a/frontend/app/tab/workspaceswitcher.scss +++ b/frontend/app/tab/workspaceswitcher.scss @@ -30,7 +30,7 @@ .workspace-switcher-content { min-height: auto; display: flex; - width: 256px; + width: 312px; padding: 0; flex-direction: column; align-items: center; @@ -66,35 +66,27 @@ } .expandable-menu { - gap: 5px; + gap: 3px; } .expandable-menu-item { margin: 3px 8px; } - .expandable-menu-item-group { + .workspace-list-item { margin: 0 8px; - border: 1px solid transparent; - border-radius: 4px; + padding: 0; --workspace-color: var(--main-bg-color); - &:last-child { - margin-bottom: 4px; - border-bottom-left-radius: 8px; - border-bottom-right-radius: 8px; - } - - .expandable-menu-item { - margin: 0; - } - .menu-group-title-wrapper { display: flex; + align-items: center; width: 100%; + min-height: 32px; padding: 5px 8px; - border-radius: 4px; + border-radius: 5px; + .icons { display: flex; flex-direction: row; @@ -115,13 +107,44 @@ visibility: visible; } - &.open { - background-color: var(--modal-bg-color); - border: 1px solid var(--modal-border-color); + &:hover .menu-group-title-wrapper { + background-color: var(--button-grey-hover-bg); + } + + .workspace-item-text { + min-width: 0; + flex: 1; + + .label, + .meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .meta { + color: var(--main-text-color); + font-size: 11px; + line-height: 15px; + opacity: 0.58; + } } + } + + .is-current { + .workspace-list-item { + .menu-group-title-wrapper { + background-color: rgb(from var(--workspace-color) r g b / 0.14); + } - &.is-current .menu-group-title-wrapper { - background-color: rgb(from var(--workspace-color) r g b / 0.1); + &:hover .menu-group-title-wrapper { + background-color: rgb(from var(--workspace-color) r g b / 0.2); + } + + .wave-iconbutton.edit { + visibility: visible; + opacity: 0.85; + } } } @@ -145,9 +168,80 @@ padding: 0; } + .expandable-menu-item.workspace-list-item { + padding: 0; + } + .actions { width: 100%; padding: 3px 0; border-top: 1px solid var(--modal-border-color); } + + .workspace-detail-page { + display: flex; + flex-direction: column; + width: 100%; + min-height: 0; + + .detail-header { + display: flex; + align-items: center; + width: 100%; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--modal-border-color); + + .wave-iconbutton.back { + width: 22px; + height: 22px; + justify-content: center; + border-radius: 4px; + + &:hover { + background-color: var(--button-grey-hover-bg); + } + } + + .detail-workspace-icon { + flex: 0 0 auto; + font-size: 14px; + } + + .detail-title { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + line-height: 18px; + } + + .wave-iconbutton.save { + width: 22px; + height: 22px; + justify-content: center; + border-radius: 4px; + opacity: 0.35; + + &.changed { + color: var(--accent-color); + opacity: 0.95; + } + + &:not(.disabled):hover { + background-color: var(--button-grey-hover-bg); + opacity: 1; + } + } + } + + .detail-scrollable { + width: 100%; + max-height: 460px; + padding: 8px 10px 10px; + } + } } diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 62e5ce2fc6..8d3d52140d 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -1,25 +1,23 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { globalStore } from "@/app/store/jotaiStore"; import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { ExpandableMenu, ExpandableMenuItem, - ExpandableMenuItemGroup, - ExpandableMenuItemGroupTitle, ExpandableMenuItemLeftElement, ExpandableMenuItemRightElement, } from "@/element/expandablemenu"; import { Popover, PopoverButton, PopoverContent } from "@/element/popover"; import { fireAndForget, makeIconClass, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; -import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { atom, PrimitiveAtom, useAtomValue, useSetAtom } from "jotai"; import { splitAtom } from "jotai/utils"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; -import { CSSProperties, forwardRef, useCallback, useEffect, useState } from "react"; +import { CSSProperties, forwardRef, useCallback, useEffect, useMemo, useState } from "react"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; -import { globalStore } from "@/app/store/jotaiStore"; import { makeORef } from "../store/wos"; import { waveEventSubscribeSingle } from "../store/wps"; import { WorkspaceEditor } from "./workspaceeditor"; @@ -45,15 +43,46 @@ type WorkspaceListEntry = { workspace: Workspace; }; +type WorkspaceDraft = { + name: string; + icon: string; + color: string; + defaultconnname: string; + defaultcwd: string; +}; + type WorkspaceList = WorkspaceListEntry[]; const workspaceMapAtom = atom([]); const workspaceSplitAtom = splitAtom(workspaceMapAtom); const editingWorkspaceAtom = atom(); + +function makeWorkspaceDraft(workspace: Workspace): WorkspaceDraft { + return { + name: workspace.name, + icon: workspace.icon, + color: workspace.color, + defaultconnname: workspace.defaultconnname ?? "", + defaultcwd: workspace.defaultcwd ?? "", + }; +} + +function isWorkspaceDraftChanged(workspace: Workspace, draft: WorkspaceDraft): boolean { + return ( + workspace.name !== draft.name || + workspace.icon !== draft.icon || + workspace.color !== draft.color || + (workspace.defaultconnname ?? "") !== draft.defaultconnname || + (workspace.defaultcwd ?? "") !== draft.defaultcwd + ); +} + const WorkspaceSwitcher = forwardRef((_, ref) => { const env = useWaveEnv(); const setWorkspaceList = useSetAtom(workspaceMapAtom); const activeWorkspace = useAtomValueSafe(env.atoms.workspace); + const workspaceEntries = useAtomValue(workspaceMapAtom); const workspaceList = useAtomValue(workspaceSplitAtom); + const editingWorkspace = useAtomValue(editingWorkspaceAtom); const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); const updateWorkspaceList = useCallback(async () => { @@ -91,6 +120,10 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { }, []); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); + const editingWorkspaceEntry = useMemo( + () => workspaceEntries.find((entry) => entry.workspace.oid === editingWorkspace), + [editingWorkspace, workspaceEntries] + ); const workspaceIcon = isActiveWorkspaceSaved ? ( @@ -101,7 +134,10 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { const saveWorkspace = () => { fireAndForget(async () => { await env.services.workspace.UpdateWorkspace( - activeWorkspace.oid, "", "", "", + activeWorkspace.oid, + "", + "", + "", activeWorkspace.defaultconnname ?? "", activeWorkspace.defaultcwd ?? "", true @@ -128,70 +164,55 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { {workspaceIcon} -
{isActiveWorkspaceSaved ? "Switch workspace" : "Open workspace"}
- - - {workspaceList.map((entry, i) => ( - - ))} - - + {editingWorkspaceEntry ? ( + + ) : ( + <> +
{isActiveWorkspaceSaved ? "Switch workspace" : "Open workspace"}
+ + + {workspaceList.map((entry, i) => ( + + ))} + + -
- {isActiveWorkspaceSaved ? ( - env.electron.createWorkspace()}> - - - -
Create new workspace
-
- ) : ( - saveWorkspace()}> - - - -
Save workspace
-
- )} -
+
+ {isActiveWorkspaceSaved ? ( + env.electron.createWorkspace()}> + + + +
Create new workspace
+
+ ) : ( + saveWorkspace()}> + + + +
Save workspace
+
+ )} +
+ + )}
); }); -const WorkspaceSwitcherItem = ({ - entryAtom, - onDeleteWorkspace, -}: { - entryAtom: PrimitiveAtom; - onDeleteWorkspace: (workspaceId: string) => void; -}) => { +const WorkspaceSwitcherItem = ({ entryAtom }: { entryAtom: PrimitiveAtom }) => { const env = useWaveEnv(); const activeWorkspace = useAtomValueSafe(env.atoms.workspace); - const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom); - const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom); + const workspaceEntry = useAtomValue(entryAtom); + const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); const workspace = workspaceEntry.workspace; const isCurrentWorkspace = activeWorkspace.oid === workspace.oid; - const setWorkspaceField = useCallback((patch: Partial) => { - const updated = { ...workspace, ...patch }; - setWorkspaceEntry({ ...workspaceEntry, workspace: updated }); - if (updated.name != "") { - fireAndForget(() => - env.services.workspace.UpdateWorkspace( - workspace.oid, - updated.name, - updated.icon, - updated.color, - updated.defaultconnname ?? "", - updated.defaultcwd ?? "", - false - ) - ); - } - }, [workspace.oid, workspaceEntry]); - const isActive = !!workspaceEntry.windowId; const editIconDecl: IconButtonDecl = { elemtype: "iconbutton", @@ -200,33 +221,23 @@ const WorkspaceSwitcherItem = ({ title: "Edit workspace", click: (e) => { e.stopPropagation(); - if (editingWorkspace === workspace.oid) { - setEditingWorkspace(null); - } else { - setEditingWorkspace(workspace.oid); - } + setEditingWorkspace(workspace.oid); }, }; const windowIconDecl: IconButtonDecl = { elemtype: "iconbutton", className: "window", noAction: true, - icon: isCurrentWorkspace ? "check" : "window", - title: isCurrentWorkspace ? "This is your current workspace" : "This workspace is open", + icon: "window", + title: "This workspace is open in another window", }; - const isEditing = editingWorkspace === workspace.oid; - return ( - - + { env.electron.switchWorkspace(workspace.oid); - // Create a fake escape key event to close the popover document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }} > @@ -244,23 +255,122 @@ const WorkspaceSwitcherItem = ({ style={{ color: workspace.color }} /> -
{workspace.name}
+
+
{workspace.name}
+ {(workspace.defaultconnname || workspace.defaultcwd) && ( +
{workspace.defaultconnname || workspace.defaultcwd}
+ )} +
- {isActive && } + {isActive && !isCurrentWorkspace && }
-
- + + + ); +}; + +const WorkspaceSwitcherDetail = ({ + entry, + onDeleteWorkspace, +}: { + entry: WorkspaceListEntry; + onDeleteWorkspace: (workspaceId: string) => void; +}) => { + const env = useWaveEnv(); + const setWorkspaceList = useSetAtom(workspaceMapAtom); + const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); + const workspace = entry.workspace; + const [draft, setDraft] = useState(() => makeWorkspaceDraft(workspace)); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setDraft(makeWorkspaceDraft(workspace)); + }, [workspace.oid]); + + const hasChanges = isWorkspaceDraftChanged(workspace, draft); + const canSave = hasChanges && draft.name !== "" && !saving; + + const setWorkspaceField = useCallback((patch: Partial) => { + setDraft((currentDraft) => ({ ...currentDraft, ...patch })); + }, []); + + const saveWorkspace = useCallback(() => { + if (!canSave) { + return; + } + fireAndForget(async () => { + setSaving(true); + try { + await env.services.workspace.UpdateWorkspace( + workspace.oid, + draft.name, + draft.icon, + draft.color, + draft.defaultconnname, + draft.defaultcwd, + false + ); + const updated = { + ...workspace, + name: draft.name, + icon: draft.icon, + color: draft.color, + defaultconnname: draft.defaultconnname, + defaultcwd: draft.defaultcwd, + }; + setWorkspaceList((workspaceEntries) => + workspaceEntries.map((workspaceEntry) => + workspaceEntry.workspace.oid === workspace.oid + ? { ...workspaceEntry, workspace: updated } + : workspaceEntry + ) + ); + } finally { + setSaving(false); + } + }); + }, [canSave, draft, workspace]); + + const backIconDecl: IconButtonDecl = { + elemtype: "iconbutton", + className: "back", + icon: "chevron-left", + title: "Back to workspaces", + click: () => setEditingWorkspace(null), + }; + const saveIconDecl: IconButtonDecl = { + elemtype: "iconbutton", + className: clsx("save", { changed: canSave }), + icon: saving ? "refresh" : "floppy-disk", + iconSpin: saving, + title: hasChanges ? "Save workspace" : "No workspace changes", + disabled: !canSave, + click: () => saveWorkspace(), + }; + + return ( +
+
+ + +
{draft.name}
+ +
+ setWorkspaceField({ name: newTitle })} onColorChange={(newColor) => setWorkspaceField({ color: newColor })} onIconChange={(newIcon) => setWorkspaceField({ icon: newIcon })} @@ -268,8 +378,8 @@ const WorkspaceSwitcherItem = ({ onCwdChange={(newCwd) => setWorkspaceField({ defaultcwd: newCwd })} onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)} /> - - + +
); }; diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index d57bcd5083..3b9a2d1c1e 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -11,6 +11,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" @@ -156,12 +157,23 @@ func (svc *WorkspaceService) GetConnectionNames_Meta() tsgenmeta.MethodMeta { func (svc *WorkspaceService) GetConnectionNames() []string { fullConfig := wconfig.GetWatcher().GetFullConfig() - names := make([]string, 0, len(fullConfig.Connections)+1) - names = append(names, "local") - for connName := range fullConfig.Connections { - if connName != "local" { - names = append(names, connName) + nameSet := make(map[string]bool, len(fullConfig.Connections)+1) + names := []string{"local"} + + addName := func(connName string) { + if connName == "" || connName == "local" || nameSet[connName] { + return } + nameSet[connName] = true + names = append(names, connName) + } + + for connName := range fullConfig.Connections { + addName(connName) + } + connNames, _ := conncontroller.GetConnectionsList() + for _, connName := range connNames { + addName(connName) } sort.Strings(names[1:]) return names From 870ea6bc16bc187f722026dec8972c8d8cb453e2 Mon Sep 17 00:00:00 2001 From: lyx-tec Date: Sat, 27 Jun 2026 13:20:00 +0800 Subject: [PATCH 3/4] Apply workspace defaults to new blocks --- cmd/test-conn/testutil.go | 4 +- frontend/app/store/global.ts | 37 +++- .../app/tab/workspaceconnectionselector.tsx | 83 +++++++++ frontend/app/tab/workspaceeditor.tsx | 84 +-------- frontend/app/tab/workspaceswitcher.tsx | 159 ++---------------- frontend/app/tab/workspaceswitcherdetail.tsx | 158 +++++++++++++++++ frontend/app/view/preview/preview-model.tsx | 12 ++ frontend/types/gotypes.d.ts | 3 + pkg/blockcontroller/shellcontroller.go | 24 ++- pkg/jobcontroller/jobcontroller.go | 2 + pkg/jobmanager/jobcmd.go | 22 +++ pkg/jobmanager/jobmanager.go | 1 + pkg/shellexec/shellexec.go | 55 +++++- pkg/shellexec/shellexec_test.go | 58 +++++++ pkg/wcore/workspace.go | 13 +- pkg/wshrpc/wshremote/wshremote_job.go | 1 + pkg/wshrpc/wshrpctypes.go | 28 +-- pkg/wshrpc/wshserver/wshserver.go | 1 + 18 files changed, 497 insertions(+), 248 deletions(-) create mode 100644 frontend/app/tab/workspaceconnectionselector.tsx create mode 100644 frontend/app/tab/workspaceswitcherdetail.tsx create mode 100644 pkg/shellexec/shellexec_test.go diff --git a/cmd/test-conn/testutil.go b/cmd/test-conn/testutil.go index f82e7b7195..7298eac6fa 100644 --- a/cmd/test-conn/testutil.go +++ b/cmd/test-conn/testutil.go @@ -159,7 +159,7 @@ func testShellWithCommand(connName string, cmd string, timeout time.Duration) er log.Printf("✓ Connected! Starting shell...") termSize := waveobj.TermSize{Rows: 24, Cols: 80} - shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) + shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn, shellutil.ShellType_unknown) if err != nil { return fmt.Errorf("failed to start shell: %w", err) } @@ -287,7 +287,7 @@ func testInteractiveShell(connName string, timeout time.Duration) error { log.Printf("Type commands and press Enter. Type 'exit' to quit.\n") termSize := waveobj.TermSize{Rows: 24, Cols: 80} - shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) + shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn, shellutil.ShellType_unknown) if err != nil { return fmt.Errorf("failed to start shell: %w", err) } diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index acc0f4d518..89c93293d4 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -359,6 +359,37 @@ function getApi(): ElectronApi { return (window as any).api; } +function applyWorkspaceDefaultsToBlockDef(blockDef: BlockDef): BlockDef { + const view = blockDef.meta?.view; + const file = blockDef.meta?.file; + const isTerm = view === "term"; + const isFileBrowser = view === "preview" && (isBlank(file) || file === "~"); + if (!isTerm && !isFileBrowser) { + return blockDef; + } + const workspace = globalStore.get(atoms.workspace); + if (workspace?.defaultconnname == null && workspace?.defaultcwd == null) { + return blockDef; + } + const meta = blockDef.meta ?? {}; + const connName = meta.connection; + const cwd = meta["cmd:cwd"]; + let nextMeta: Record = meta; + if (workspace.defaultconnname && isLocalConnName(connName)) { + nextMeta = { ...nextMeta, connection: workspace.defaultconnname }; + } + if (workspace.defaultcwd && isTerm && isBlank(cwd)) { + nextMeta = { ...nextMeta, "cmd:cwd": workspace.defaultcwd }; + } + if (workspace.defaultcwd && isFileBrowser) { + nextMeta = { ...nextMeta, file: workspace.defaultcwd, "file:workspacecwd": true }; + } + if (nextMeta === meta) { + return blockDef; + } + return { ...blockDef, meta: nextMeta }; +} + async function createBlockSplitHorizontally( blockDef: BlockDef, targetBlockId: string, @@ -366,6 +397,7 @@ async function createBlockSplitHorizontally( ): Promise { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + blockDef = applyWorkspaceDefaultsToBlockDef(blockDef); const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); const targetNodeId = layoutModel.getNodeByBlockId(targetBlockId)?.id; if (targetNodeId == null) { @@ -389,6 +421,7 @@ async function createBlockSplitVertically( ): Promise { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + blockDef = applyWorkspaceDefaultsToBlockDef(blockDef); const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); const targetNodeId = layoutModel.getNodeByBlockId(targetBlockId)?.id; if (targetNodeId == null) { @@ -408,6 +441,7 @@ async function createBlockSplitVertically( async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = false): Promise { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + blockDef = applyWorkspaceDefaultsToBlockDef(blockDef); const blockId = await ObjectService.CreateBlock(blockDef, rtOpts); if (ephemeral) { layoutModel.newEphemeralNode(blockId); @@ -426,6 +460,7 @@ async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = fa async function replaceBlock(blockId: string, blockDef: BlockDef, focus: boolean): Promise { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + blockDef = applyWorkspaceDefaultsToBlockDef(blockDef); const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); setTimeout(() => { fireAndForget(() => ObjectService.DeleteBlock(blockId)); @@ -685,7 +720,6 @@ export { getBlockComponentModel, getBlockMetaKeyAtom, getBlockTermDurableAtom, - getTabMetaKeyAtom, getConfigBackgroundAtom, getConnConfigKeyAtom, getConnStatusAtom, @@ -697,6 +731,7 @@ export { getOverrideConfigAtom, getSettingsKeyAtom, getSettingsPrefixAtom, + getTabMetaKeyAtom, getUserName, globalPrimaryTabStartup, globalStore, diff --git a/frontend/app/tab/workspaceconnectionselector.tsx b/frontend/app/tab/workspaceconnectionselector.tsx new file mode 100644 index 0000000000..78bf58b0b3 --- /dev/null +++ b/frontend/app/tab/workspaceconnectionselector.tsx @@ -0,0 +1,83 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { Input } from "../element/input"; + +interface ConnectionSelectorProps { + connectionNames: string[]; + value: string; + onChange: (value: string) => void; +} + +const WorkspaceConnectionSelectorComponent = ({ connectionNames, value, onChange }: ConnectionSelectorProps) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const items = useMemo(() => { + const sorted = [...connectionNames].sort(); + return [ + { label: "(none)", value: "" }, + { divider: true as const }, + ...sorted.map((n) => ({ label: n, value: n })), + ]; + }, [connectionNames]); + + const handleSelect = (val: string) => { + onChange(val); + setOpen(false); + }; + + return ( +
+
+ setOpen(true)} + /> + setOpen(!open)} + /> +
+ {open && ( + + {items.map((item, idx) => { + if ("divider" in item) { + return
; + } + return ( +
handleSelect(item.value)} + > + {item.label} +
+ ); + })} + + )} +
+ ); +}; + +export const WorkspaceConnectionSelector = memo(WorkspaceConnectionSelectorComponent); diff --git a/frontend/app/tab/workspaceeditor.tsx b/frontend/app/tab/workspaceeditor.tsx index c9306d129e..270d4024b2 100644 --- a/frontend/app/tab/workspaceeditor.tsx +++ b/frontend/app/tab/workspaceeditor.tsx @@ -1,10 +1,10 @@ import { fireAndForget, makeIconClass } from "@/util/util"; import clsx from "clsx"; -import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; -import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { Button } from "../element/button"; import { Input } from "../element/input"; import { WorkspaceService } from "../store/services"; +import { WorkspaceConnectionSelector } from "./workspaceconnectionselector"; import "./workspaceeditor.scss"; interface ColorSelectorProps { @@ -61,80 +61,6 @@ const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSel ); }); -interface ConnectionSelectorProps { - connectionNames: string[]; - value: string; - onChange: (value: string) => void; -} - -const ConnectionSelector = memo(({ connectionNames, value, onChange }: ConnectionSelectorProps) => { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - const items = useMemo(() => { - const sorted = [...connectionNames].sort(); - return [ - { label: "(none)", value: "" }, - { divider: true as const }, - ...sorted.map((n) => ({ label: n, value: n })), - ]; - }, [connectionNames]); - - const handleSelect = (val: string) => { - onChange(val); - setOpen(false); - }; - - return ( -
-
- setOpen(true)} - /> - setOpen(!open)} - /> -
- {open && ( - - {items.map((item, idx) => { - if ("divider" in item) { - return
; - } - return ( -
handleSelect(item.value)} - > - {item.label} -
- ); - })} - - )} -
- ); -}); - interface WorkspaceEditorProps { title: string; icon: string; @@ -200,7 +126,11 @@ const WorkspaceEditorComponent = ({ autoFocus autoSelect /> - + diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 8d3d52140d..b660f17f2e 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -15,13 +15,13 @@ import clsx from "clsx"; import { atom, PrimitiveAtom, useAtomValue, useSetAtom } from "jotai"; import { splitAtom } from "jotai/utils"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; -import { CSSProperties, forwardRef, useCallback, useEffect, useMemo, useState } from "react"; +import { CSSProperties, forwardRef, useCallback, useEffect, useMemo } from "react"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; import { makeORef } from "../store/wos"; import { waveEventSubscribeSingle } from "../store/wps"; -import { WorkspaceEditor } from "./workspaceeditor"; import "./workspaceswitcher.scss"; +import { WorkspaceSwitcherDetail } from "./workspaceswitcherdetail"; export type WorkspaceSwitcherEnv = WaveEnvSubset<{ electron: { @@ -43,39 +43,11 @@ type WorkspaceListEntry = { workspace: Workspace; }; -type WorkspaceDraft = { - name: string; - icon: string; - color: string; - defaultconnname: string; - defaultcwd: string; -}; - type WorkspaceList = WorkspaceListEntry[]; const workspaceMapAtom = atom([]); const workspaceSplitAtom = splitAtom(workspaceMapAtom); const editingWorkspaceAtom = atom(); -function makeWorkspaceDraft(workspace: Workspace): WorkspaceDraft { - return { - name: workspace.name, - icon: workspace.icon, - color: workspace.color, - defaultconnname: workspace.defaultconnname ?? "", - defaultcwd: workspace.defaultcwd ?? "", - }; -} - -function isWorkspaceDraftChanged(workspace: Workspace, draft: WorkspaceDraft): boolean { - return ( - workspace.name !== draft.name || - workspace.icon !== draft.icon || - workspace.color !== draft.color || - (workspace.defaultconnname ?? "") !== draft.defaultconnname || - (workspace.defaultcwd ?? "") !== draft.defaultcwd - ); -} - const WorkspaceSwitcher = forwardRef((_, ref) => { const env = useWaveEnv(); const setWorkspaceList = useSetAtom(workspaceMapAtom); @@ -119,6 +91,16 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { env.electron.deleteWorkspace(workspaceId); }, []); + const onWorkspaceUpdated = useCallback((updatedWorkspace: Workspace) => { + setWorkspaceList((workspaceEntries) => + workspaceEntries.map((workspaceEntry) => + workspaceEntry.workspace.oid === updatedWorkspace.oid + ? { ...workspaceEntry, workspace: updatedWorkspace } + : workspaceEntry + ) + ); + }, []); + const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); const editingWorkspaceEntry = useMemo( () => workspaceEntries.find((entry) => entry.workspace.oid === editingWorkspace), @@ -165,7 +147,12 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { {editingWorkspaceEntry ? ( - + setEditingWorkspace(null)} + onDeleteWorkspace={onDeleteWorkspace} + onWorkspaceUpdated={onWorkspaceUpdated} + /> ) : ( <>
{isActiveWorkspaceSaved ? "Switch workspace" : "Open workspace"}
@@ -273,114 +260,4 @@ const WorkspaceSwitcherItem = ({ entryAtom }: { entryAtom: PrimitiveAtom void; -}) => { - const env = useWaveEnv(); - const setWorkspaceList = useSetAtom(workspaceMapAtom); - const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); - const workspace = entry.workspace; - const [draft, setDraft] = useState(() => makeWorkspaceDraft(workspace)); - const [saving, setSaving] = useState(false); - - useEffect(() => { - setDraft(makeWorkspaceDraft(workspace)); - }, [workspace.oid]); - - const hasChanges = isWorkspaceDraftChanged(workspace, draft); - const canSave = hasChanges && draft.name !== "" && !saving; - - const setWorkspaceField = useCallback((patch: Partial) => { - setDraft((currentDraft) => ({ ...currentDraft, ...patch })); - }, []); - - const saveWorkspace = useCallback(() => { - if (!canSave) { - return; - } - fireAndForget(async () => { - setSaving(true); - try { - await env.services.workspace.UpdateWorkspace( - workspace.oid, - draft.name, - draft.icon, - draft.color, - draft.defaultconnname, - draft.defaultcwd, - false - ); - const updated = { - ...workspace, - name: draft.name, - icon: draft.icon, - color: draft.color, - defaultconnname: draft.defaultconnname, - defaultcwd: draft.defaultcwd, - }; - setWorkspaceList((workspaceEntries) => - workspaceEntries.map((workspaceEntry) => - workspaceEntry.workspace.oid === workspace.oid - ? { ...workspaceEntry, workspace: updated } - : workspaceEntry - ) - ); - } finally { - setSaving(false); - } - }); - }, [canSave, draft, workspace]); - - const backIconDecl: IconButtonDecl = { - elemtype: "iconbutton", - className: "back", - icon: "chevron-left", - title: "Back to workspaces", - click: () => setEditingWorkspace(null), - }; - const saveIconDecl: IconButtonDecl = { - elemtype: "iconbutton", - className: clsx("save", { changed: canSave }), - icon: saving ? "refresh" : "floppy-disk", - iconSpin: saving, - title: hasChanges ? "Save workspace" : "No workspace changes", - disabled: !canSave, - click: () => saveWorkspace(), - }; - - return ( -
-
- - -
{draft.name}
- -
- - setWorkspaceField({ name: newTitle })} - onColorChange={(newColor) => setWorkspaceField({ color: newColor })} - onIconChange={(newIcon) => setWorkspaceField({ icon: newIcon })} - onConnNameChange={(newConnName) => setWorkspaceField({ defaultconnname: newConnName })} - onCwdChange={(newCwd) => setWorkspaceField({ defaultcwd: newCwd })} - onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)} - /> - -
- ); -}; - export { WorkspaceSwitcher }; diff --git a/frontend/app/tab/workspaceswitcherdetail.tsx b/frontend/app/tab/workspaceswitcherdetail.tsx new file mode 100644 index 0000000000..5b76b8aa8f --- /dev/null +++ b/frontend/app/tab/workspaceswitcherdetail.tsx @@ -0,0 +1,158 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import { fireAndForget, makeIconClass } from "@/util/util"; +import clsx from "clsx"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import { useCallback, useEffect, useState } from "react"; +import { IconButton } from "../element/iconbutton"; +import { WorkspaceEditor } from "./workspaceeditor"; + +type WorkspaceSwitcherDetailEnv = WaveEnvSubset<{ + services: { + workspace: WaveEnv["services"]["workspace"]; + }; +}>; + +type WorkspaceListEntry = { + windowId: string; + workspace: Workspace; +}; + +type WorkspaceDraft = { + name: string; + icon: string; + color: string; + defaultconnname: string; + defaultcwd: string; +}; + +function makeWorkspaceDraft(workspace: Workspace): WorkspaceDraft { + return { + name: workspace.name, + icon: workspace.icon, + color: workspace.color, + defaultconnname: workspace.defaultconnname ?? "", + defaultcwd: workspace.defaultcwd ?? "", + }; +} + +function isWorkspaceDraftChanged(workspace: Workspace, draft: WorkspaceDraft): boolean { + return ( + workspace.name !== draft.name || + workspace.icon !== draft.icon || + workspace.color !== draft.color || + (workspace.defaultconnname ?? "") !== draft.defaultconnname || + (workspace.defaultcwd ?? "") !== draft.defaultcwd + ); +} + +type WorkspaceSwitcherDetailProps = { + entry: WorkspaceListEntry; + onBack: () => void; + onDeleteWorkspace: (workspaceId: string) => void; + onWorkspaceUpdated: (workspace: Workspace) => void; +}; + +const WorkspaceSwitcherDetail = ({ + entry, + onBack, + onDeleteWorkspace, + onWorkspaceUpdated, +}: WorkspaceSwitcherDetailProps) => { + const env = useWaveEnv(); + const workspace = entry.workspace; + const [draft, setDraft] = useState(() => makeWorkspaceDraft(workspace)); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setDraft(makeWorkspaceDraft(workspace)); + }, [workspace.oid]); + + const hasChanges = isWorkspaceDraftChanged(workspace, draft); + const canSave = hasChanges && draft.name !== "" && !saving; + + const setWorkspaceField = useCallback((patch: Partial) => { + setDraft((currentDraft) => ({ ...currentDraft, ...patch })); + }, []); + + const saveWorkspace = useCallback(() => { + if (!canSave) { + return; + } + fireAndForget(async () => { + setSaving(true); + try { + await env.services.workspace.UpdateWorkspace( + workspace.oid, + draft.name, + draft.icon, + draft.color, + draft.defaultconnname, + draft.defaultcwd, + false + ); + onWorkspaceUpdated({ + ...workspace, + name: draft.name, + icon: draft.icon, + color: draft.color, + defaultconnname: draft.defaultconnname, + defaultcwd: draft.defaultcwd, + }); + } finally { + setSaving(false); + } + }); + }, [canSave, draft, workspace]); + + const backIconDecl: IconButtonDecl = { + elemtype: "iconbutton", + className: "back", + icon: "chevron-left", + title: "Back to workspaces", + click: onBack, + }; + const saveIconDecl: IconButtonDecl = { + elemtype: "iconbutton", + className: clsx("save", { changed: canSave }), + icon: saving ? "refresh" : "floppy-disk", + iconSpin: saving, + title: hasChanges ? "Save workspace" : "No workspace changes", + disabled: !canSave, + click: () => saveWorkspace(), + }; + + return ( +
+
+ + +
{draft.name}
+ +
+ + setWorkspaceField({ name: newTitle })} + onColorChange={(newColor) => setWorkspaceField({ color: newColor })} + onIconChange={(newIcon) => setWorkspaceField({ icon: newIcon })} + onConnNameChange={(newConnName) => setWorkspaceField({ defaultconnname: newConnName })} + onCwdChange={(newCwd) => setWorkspaceField({ defaultcwd: newCwd })} + onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)} + /> + +
+ ); +}; + +export { WorkspaceSwitcherDetail }; diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index b9c69f2d07..52e47bf8ed 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -417,6 +417,15 @@ export class PreviewModel implements ViewModel { path, }, }); + const blockMeta = get(this.blockAtom)?.meta as Record; + if (blockMeta?.["file:workspacecwd"] && (statFile?.notfound || !statFile?.isdir)) { + const fallbackPath = await this.formatRemoteUri("~", get); + return await this.env.rpc.FileInfoCommand(TabRpcClient, { + info: { + path: fallbackPath, + }, + }); + } return statFile; } catch (e) { const errorStatus: ErrorMsg = { @@ -591,6 +600,7 @@ export class PreviewModel implements ViewModel { if (updateMeta == null) { return; } + (updateMeta as Record)["file:workspacecwd"] = null; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); @@ -627,6 +637,7 @@ export class PreviewModel implements ViewModel { return; } updateMeta.edit = false; + (updateMeta as Record)["file:workspacecwd"] = null; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } @@ -639,6 +650,7 @@ export class PreviewModel implements ViewModel { return; } updateMeta.edit = false; + (updateMeta as Record)["file:workspacecwd"] = null; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 5991d4b661..3c25b0d032 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -451,6 +451,7 @@ declare global { cmd: string; args: string[]; env: {[key: string]: string}; + cwd?: string; termsize?: TermSize; }; @@ -602,6 +603,7 @@ declare global { cmd: string; args: string[]; env: {[key: string]: string}; + cwd?: string; termsize: TermSize; streammeta?: StreamMeta; jobauthtoken: string; @@ -706,6 +708,7 @@ declare global { cmd: string; args: string[]; env: {[key: string]: string}; + cwd?: string; termsize: TermSize; streammeta?: StreamMeta; }; diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go index 7d17245aec..227465b5a5 100644 --- a/pkg/blockcontroller/shellcontroller.go +++ b/pkg/blockcontroller/shellcontroller.go @@ -403,7 +403,7 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc cmdOpts.Interactive = true cmdOpts.Login = true cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") - if cmdOpts.Cwd != "" { + if cmdOpts.Cwd != "" && connUnion.ConnType == ConnType_Local { cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd) if err != nil { return nil, err @@ -427,7 +427,7 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc if connUnion.ConnType == ConnType_Wsl { wslConn := connUnion.WslConn if !connUnion.WshEnabled { - shellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn) + shellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn, connUnion.ShellType) if err != nil { return nil, err } @@ -451,7 +451,7 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc wslConn.WshEnabled.Store(false) blocklogger.Infof(logCtx, "[conndebug] error starting wsl shell proc with wsh: %v\n", err) blocklogger.Infof(logCtx, "[conndebug] attempting install without wsh\n") - shellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn) + shellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn, connUnion.ShellType) if err != nil { return nil, err } @@ -460,7 +460,7 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc } else if connUnion.ConnType == ConnType_Ssh { conn := connUnion.SshConn if !connUnion.WshEnabled { - shellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn) + shellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn, connUnion.ShellType) if err != nil { return nil, err } @@ -476,7 +476,7 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc conn.WshEnabled.Store(false) blocklogger.Infof(logCtx, "[conndebug] error starting remote shell proc with wsh: %v\n", err) blocklogger.Infof(logCtx, "[conndebug] attempting install without wsh\n") - shellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn) + shellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn, connUnion.ShellType) if err != nil { return nil, err } @@ -614,10 +614,16 @@ func (bc *ShellController) manageRunningShellProcess(shellProc *shellexec.ShellP } func (union *ConnUnion) getRemoteInfoAndShellType(blockMeta waveobj.MetaMapType) error { - if !union.WshEnabled { - return nil - } if union.ConnType == ConnType_Ssh || union.ConnType == ConnType_Wsl { + if !union.WshEnabled { + if union.ConnType == ConnType_Ssh { + union.ShellPath = union.SshConn.GetConfigShellPath() + } else { + union.ShellPath = union.WslConn.GetConfigShellPath() + } + union.ShellType = shellutil.GetShellTypeFromShellPath(union.ShellPath) + return nil + } connRoute := wshutil.MakeConnectionRouteId(union.ConnName) remoteInfo, err := wshclient.RemoteGetInfoCommand(wshclient.GetBareRpcClient(), &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) if err != nil { @@ -715,7 +721,7 @@ func createCmdStrAndOpts(blockId string, blockMeta waveobj.MetaMapType, connName return "", nil, fmt.Errorf("missing cmd in block meta") } cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") - if cmdOpts.Cwd != "" { + if cmdOpts.Cwd != "" && conncontroller.IsLocalConnName(connName) { cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd) if err != nil { return "", nil, err diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 5e73202caa..9655e632b8 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -627,6 +627,7 @@ type StartJobParams struct { Cmd string Args []string Env map[string]string + Cwd string TermSize *waveobj.TermSize BlockId string } @@ -718,6 +719,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { Cmd: params.Cmd, Args: params.Args, Env: jobEnv, + Cwd: params.Cwd, TermSize: *params.TermSize, StreamMeta: streamMeta, JobAuthToken: jobAuthToken, diff --git a/pkg/jobmanager/jobcmd.go b/pkg/jobmanager/jobcmd.go index 0c82a690d6..420c069020 100644 --- a/pkg/jobmanager/jobcmd.go +++ b/pkg/jobmanager/jobcmd.go @@ -9,6 +9,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "sync" "syscall" @@ -24,6 +25,7 @@ type CmdDef struct { Cmd string Args []string Env map[string]string + Cwd string TermSize waveobj.TermSize } @@ -43,6 +45,20 @@ type JobCmd struct { exitTs int64 } +func expandRemoteHome(pathStr string) string { + if pathStr != "~" && !strings.HasPrefix(pathStr, "~/") { + return filepath.Clean(pathStr) + } + homeDir, err := os.UserHomeDir() + if err != nil || homeDir == "" { + return filepath.Clean(pathStr) + } + if pathStr == "~" { + return homeDir + } + return filepath.Clean(filepath.Join(homeDir, pathStr[2:])) +} + func MakeJobCmd(jobId string, cmdDef CmdDef) (*JobCmd, error) { jm := &JobCmd{ jobId: jobId, @@ -56,6 +72,12 @@ func MakeJobCmd(jobId string, cmdDef CmdDef) (*JobCmd, error) { } ecmd := exec.Command(cmdDef.Cmd, cmdDef.Args...) ecmd.Env = mergeEnv(os.Environ(), cmdDef.Env) + if cmdDef.Cwd != "" { + ecmd.Dir = expandRemoteHome(cmdDef.Cwd) + if info, err := os.Stat(ecmd.Dir); err != nil || !info.IsDir() { + ecmd.Dir, _ = os.UserHomeDir() + } + } cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(cmdDef.TermSize.Rows), Cols: uint16(cmdDef.TermSize.Cols)}) if err != nil { return nil, fmt.Errorf("failed to start command: %w", err) diff --git a/pkg/jobmanager/jobmanager.go b/pkg/jobmanager/jobmanager.go index dd58bccc52..f4df588805 100644 --- a/pkg/jobmanager/jobmanager.go +++ b/pkg/jobmanager/jobmanager.go @@ -232,6 +232,7 @@ func (jm *JobManager) StartJob(msc *MainServerConn, data wshrpc.CommandStartJobD Cmd: data.Cmd, Args: data.Args, Env: data.Env, + Cwd: data.Cwd, TermSize: data.TermSize, } log.Printf("StartJob: creating job cmd for jobid=%s", jm.JobId) diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index b8ff6f55d9..092c691429 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -111,12 +111,52 @@ func checkCwd(cwd string) error { if cwd == "" { return fmt.Errorf("cwd is empty") } - if _, err := os.Stat(cwd); err != nil { + info, err := os.Stat(cwd) + if err != nil { return fmt.Errorf("error statting cwd %q: %w", cwd, err) } + if !info.IsDir() { + return fmt.Errorf("cwd %q is not a directory", cwd) + } return nil } +func remoteCwdPrefix(cwd string, homeDir string, shellType string) string { + if cwd == "" { + return "" + } + if homeDir == "" { + homeDir = "~" + } + if strings.HasPrefix(cwd, "~/") { + cwd = homeDir + cwd[1:] + } else if cwd == "~" { + cwd = homeDir + } + switch shellType { + case shellutil.ShellType_fish: + return fmt.Sprintf("cd %s 2>/dev/null; or cd %s; ", shellutil.HardQuoteFish(cwd), shellutil.HardQuoteFish(homeDir)) + case shellutil.ShellType_pwsh: + return fmt.Sprintf("Set-Location -LiteralPath %s -ErrorAction SilentlyContinue; if (-not $?) { Set-Location -LiteralPath %s }; ", shellutil.HardQuotePowerShell(cwd), shellutil.HardQuotePowerShell(homeDir)) + default: + return fmt.Sprintf("cd %s 2>/dev/null || cd %s; ", shellutil.HardQuote(cwd), shellutil.HardQuote(homeDir)) + } +} + +func remoteCwdInitCommand(cwd string, shellType string) string { + if cwd == "" { + return "" + } + if shellType == "" || shellType == shellutil.ShellType_unknown { + return fmt.Sprintf("cd %s\n", shellutil.HardQuote(cwd)) + } + prefix := remoteCwdPrefix(cwd, "~", shellType) + if prefix == "" { + return "" + } + return prefix + "\n" +} + type PipePty struct { remoteStdinWrite *os.File remoteStdoutRead *os.File @@ -152,7 +192,7 @@ func (pp *PipePty) WriteString(s string) (n int, err error) { return pp.Write([]byte(s)) } -func StartWslShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn) (*ShellProc, error) { +func StartWslShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn, shellType string) (*ShellProc, error) { client := conn.GetClient() conn.Infof(ctx, "WSL-NEWSESSION (StartWslShellProcNoWsh)") @@ -169,6 +209,9 @@ func StartWslShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdS if err != nil { return nil, err } + if cwdCmd := remoteCwdInitCommand(cmdOpts.Cwd, shellType); cwdCmd != "" { + _, _ = cmdPty.WriteString(cwdCmd) + } cmdWrap := MakeCmdWrap(ecmd, cmdPty, true) return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } @@ -273,6 +316,7 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st conn.Debugf(ctx, "adding JWT token to environment\n") cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined) } + cmdCombined = remoteCwdPrefix(cmdOpts.Cwd, remoteInfo.HomeDir, shellutil.ShellType_unknown) + cmdCombined log.Printf("full combined command: %s", cmdCombined) ecmd := exec.Command("wsl.exe", "~", "-d", client.Name(), "--", "sh", "-c", cmdCombined) if termSize.Rows == 0 || termSize.Cols == 0 { @@ -291,7 +335,7 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } -func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { +func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn, shellType string) (*ShellProc, error) { client := conn.GetClient() conn.Infof(ctx, "SSH-NEWSESSION (StartRemoteShellProcNoWsh)") session, err := client.NewSession() @@ -346,6 +390,9 @@ func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, c cleanup() return nil, err } + if cwdCmd := remoteCwdInitCommand(cmdOpts.Cwd, shellType); cwdCmd != "" { + _, _ = pipePty.WriteString(cwdCmd) + } return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } @@ -484,6 +531,7 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize conn.Debugf(logCtx, "adding JWT token to environment\n") cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined) } + cmdCombined = remoteCwdPrefix(cmdOpts.Cwd, remoteInfo.HomeDir, shellType) + cmdCombined shellutil.AddTokenSwapEntry(cmdOpts.SwapToken) err = session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) if err != nil { @@ -599,6 +647,7 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w Cmd: shellPath, Args: shellOpts, Env: env, + Cwd: cmdOpts.Cwd, TermSize: &termSize, BlockId: optBlockId, } diff --git a/pkg/shellexec/shellexec_test.go b/pkg/shellexec/shellexec_test.go new file mode 100644 index 0000000000..b7f0c270ea --- /dev/null +++ b/pkg/shellexec/shellexec_test.go @@ -0,0 +1,58 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package shellexec + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wavetermdev/waveterm/pkg/util/shellutil" +) + +func TestCheckCwdRequiresDirectory(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "file.txt") + if err := os.WriteFile(filePath, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + if err := checkCwd(tempDir); err != nil { + t.Fatalf("checkCwd directory error = %v", err) + } + if err := checkCwd(filePath); err == nil { + t.Fatal("checkCwd file error = nil") + } +} + +func TestRemoteCwdPrefixUsesShellSyntax(t *testing.T) { + pwshPrefix := remoteCwdPrefix("C:\\Users\\me\\project", "C:\\Users\\me", shellutil.ShellType_pwsh) + if !strings.Contains(pwshPrefix, "Set-Location") { + t.Fatalf("PowerShell prefix = %q, expected Set-Location", pwshPrefix) + } + if strings.Contains(pwshPrefix, " || ") { + t.Fatalf("PowerShell prefix = %q, should not use POSIX fallback syntax", pwshPrefix) + } + + posixPrefix := remoteCwdPrefix("/tmp/project", "/home/me", shellutil.ShellType_unknown) + if !strings.Contains(posixPrefix, " || ") { + t.Fatalf("POSIX prefix = %q, expected POSIX fallback syntax", posixPrefix) + } +} + +func TestRemoteCwdInitCommandUsesConservativeUnknownSyntax(t *testing.T) { + unknownCmd := remoteCwdInitCommand("/tmp/project", shellutil.ShellType_unknown) + if !strings.HasPrefix(unknownCmd, "cd ") { + t.Fatalf("unknown init command = %q, expected cd", unknownCmd) + } + if strings.Contains(unknownCmd, " || ") { + t.Fatalf("unknown init command = %q, should not use POSIX fallback syntax", unknownCmd) + } + + pwshCmd := remoteCwdInitCommand("C:\\Users\\me\\project", shellutil.ShellType_pwsh) + if !strings.Contains(pwshCmd, "Set-Location") { + t.Fatalf("PowerShell init command = %q, expected Set-Location", pwshCmd) + } +} diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index cb23c4d309..09c1e2281a 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -316,19 +316,28 @@ func applyWorkspaceDefaultsToBlocks(ctx context.Context, workspaceId string, tab if meta == nil { meta = make(waveobj.MetaMapType) } + view, _ := meta[waveobj.MetaKey_View].(string) + isTerm := view == "term" + file, _ := meta[waveobj.MetaKey_File].(string) + isFileBrowser := view == "preview" && (file == "" || file == "~") updated := false - if ws.DefaultConnName != "" { + if ws.DefaultConnName != "" && (isTerm || isFileBrowser) { if _, exists := meta[waveobj.MetaKey_Connection]; !exists { meta[waveobj.MetaKey_Connection] = ws.DefaultConnName updated = true } } - if ws.DefaultCwd != "" { + if ws.DefaultCwd != "" && isTerm { if _, exists := meta[waveobj.MetaKey_CmdCwd]; !exists { meta[waveobj.MetaKey_CmdCwd] = ws.DefaultCwd updated = true } } + if ws.DefaultCwd != "" && isFileBrowser { + meta[waveobj.MetaKey_File] = ws.DefaultCwd + meta["file:workspacecwd"] = true + updated = true + } if updated { block.Meta = meta wstore.DBUpdate(ctx, block) diff --git a/pkg/wshrpc/wshremote/wshremote_job.go b/pkg/wshrpc/wshremote/wshremote_job.go index ccf540505d..bec432c020 100644 --- a/pkg/wshrpc/wshremote/wshremote_job.go +++ b/pkg/wshrpc/wshremote/wshremote_job.go @@ -283,6 +283,7 @@ func (impl *ServerImpl) RemoteStartJobCommand(ctx context.Context, data wshrpc.C Cmd: data.Cmd, Args: data.Args, Env: combinedEnv, + Cwd: data.Cwd, TermSize: data.TermSize, StreamMeta: data.StreamMeta, } diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index ed8e1ed860..fd4477a9b0 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -351,7 +351,6 @@ type CommandEventReadHistoryData struct { MaxItems int `json:"maxitems"` } - type CpuDataRequest struct { Id string `json:"id"` Count int `json:"count"` @@ -738,6 +737,7 @@ type CommandStartJobData struct { Cmd string `json:"cmd"` Args []string `json:"args"` Env map[string]string `json:"env"` + Cwd string `json:"cwd,omitempty"` TermSize waveobj.TermSize `json:"termsize"` StreamMeta *StreamMeta `json:"streammeta,omitempty"` } @@ -746,6 +746,7 @@ type CommandRemoteStartJobData struct { Cmd string `json:"cmd"` Args []string `json:"args"` Env map[string]string `json:"env"` + Cwd string `json:"cwd,omitempty"` TermSize waveobj.TermSize `json:"termsize"` StreamMeta *StreamMeta `json:"streammeta,omitempty"` JobAuthToken string `json:"jobauthtoken"` @@ -821,6 +822,7 @@ type CommandJobControllerStartJobData struct { Cmd string `json:"cmd"` Args []string `json:"args"` Env map[string]string `json:"env"` + Cwd string `json:"cwd,omitempty"` TermSize *waveobj.TermSize `json:"termsize,omitempty"` } @@ -978,16 +980,16 @@ type CommandRecordSessionActivityData struct { } type SessionInfoRtnData struct { - DaemonId string `json:"daemonid"` - Name string `json:"name"` - Connection string `json:"connection"` - JobId string `json:"jobid,omitempty"` - IsAnonymous bool `json:"isanonymous"` - Status string `json:"status"` - Cwd string `json:"cwd,omitempty"` - CreatedAt int64 `json:"createdat"` - IdleTimeout int64 `json:"idletimeout"` - IdleSince int64 `json:"idlesince,omitempty"` - LastActiveAt int64 `json:"lastactiveat,omitempty"` - Blocks []string `json:"blocks,omitempty"` + DaemonId string `json:"daemonid"` + Name string `json:"name"` + Connection string `json:"connection"` + JobId string `json:"jobid,omitempty"` + IsAnonymous bool `json:"isanonymous"` + Status string `json:"status"` + Cwd string `json:"cwd,omitempty"` + CreatedAt int64 `json:"createdat"` + IdleTimeout int64 `json:"idletimeout"` + IdleSince int64 `json:"idlesince,omitempty"` + LastActiveAt int64 `json:"lastactiveat,omitempty"` + Blocks []string `json:"blocks,omitempty"` } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 1b3f781b6d..8e39b8c97c 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1569,6 +1569,7 @@ func (ws *WshServer) JobControllerStartJobCommand(ctx context.Context, data wshr Cmd: data.Cmd, Args: data.Args, Env: data.Env, + Cwd: data.Cwd, TermSize: data.TermSize, } return jobcontroller.StartJob(ctx, params) From 7fb9ff0dc8d32cd841919b0888007f775ce41198 Mon Sep 17 00:00:00 2001 From: lyx-tec Date: Sat, 27 Jun 2026 16:35:25 +0800 Subject: [PATCH 4/4] Use file cwd for workspace file defaults --- frontend/app/store/global.ts | 2 +- frontend/app/view/preview/preview-model.tsx | 21 ++++++++++++--------- frontend/types/gotypes.d.ts | 1 + pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 7 ++++--- pkg/wcore/workspace.go | 4 ++-- 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 89c93293d4..8096a6162f 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -382,7 +382,7 @@ function applyWorkspaceDefaultsToBlockDef(blockDef: BlockDef): BlockDef { nextMeta = { ...nextMeta, "cmd:cwd": workspace.defaultcwd }; } if (workspace.defaultcwd && isFileBrowser) { - nextMeta = { ...nextMeta, file: workspace.defaultcwd, "file:workspacecwd": true }; + nextMeta = { ...nextMeta, file: "", "file:cwd": workspace.defaultcwd }; } if (nextMeta === meta) { return blockDef; diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 52e47bf8ed..119361b70f 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -382,11 +382,16 @@ export class PreviewModel implements ViewModel { return null; }); this.metaFilePath = atom((get) => { - const file = get(this.blockAtom)?.meta?.file; - if (file == null) { - return "~"; + const blockMeta = get(this.blockAtom)?.meta; + const file = blockMeta?.file; + if (!isBlank(file)) { + return file; + } + const cwd = blockMeta?.["file:cwd"]; + if (!isBlank(cwd)) { + return cwd; } - return file; + return "~"; }); this.statFilePath = atom>(async (get) => { const fileInfo = await get(this.statFile); @@ -417,8 +422,9 @@ export class PreviewModel implements ViewModel { path, }, }); - const blockMeta = get(this.blockAtom)?.meta as Record; - if (blockMeta?.["file:workspacecwd"] && (statFile?.notfound || !statFile?.isdir)) { + const blockMeta = get(this.blockAtom)?.meta; + const usesInitialCwd = isBlank(blockMeta?.file) && !isBlank(blockMeta?.["file:cwd"]); + if (usesInitialCwd && (statFile?.notfound || !statFile?.isdir)) { const fallbackPath = await this.formatRemoteUri("~", get); return await this.env.rpc.FileInfoCommand(TabRpcClient, { info: { @@ -600,7 +606,6 @@ export class PreviewModel implements ViewModel { if (updateMeta == null) { return; } - (updateMeta as Record)["file:workspacecwd"] = null; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); @@ -637,7 +642,6 @@ export class PreviewModel implements ViewModel { return; } updateMeta.edit = false; - (updateMeta as Record)["file:workspacecwd"] = null; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } @@ -650,7 +654,6 @@ export class PreviewModel implements ViewModel { return; } updateMeta.edit = false; - (updateMeta as Record)["file:workspacecwd"] = null; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 3c25b0d032..f81f605688 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1143,6 +1143,7 @@ declare global { view?: string; controller?: string; file?: string; + "file:cwd"?: string; url?: string; pinnedurl?: string; connection?: string; diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index be1bd4f077..d7ccff18e2 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -11,6 +11,7 @@ const ( MetaKey_Controller = "controller" MetaKey_File = "file" + MetaKey_FileCwd = "file:cwd" MetaKey_Url = "url" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index be2283d082..baee748cce 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -15,6 +15,7 @@ type MetaTSType struct { View string `json:"view,omitempty"` Controller string `json:"controller,omitempty"` File string `json:"file,omitempty"` + FileCwd string `json:"file:cwd,omitempty"` Url string `json:"url,omitempty"` PinnedUrl string `json:"pinnedurl,omitempty"` Connection string `json:"connection,omitempty"` @@ -61,7 +62,7 @@ type MetaTSType struct { CmdInitScriptBash string `json:"cmd:initscript.bash,omitempty"` CmdInitScriptZsh string `json:"cmd:initscript.zsh,omitempty"` CmdInitScriptPwsh string `json:"cmd:initscript.pwsh,omitempty"` - CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"` + CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"` SessionDaemonId string `json:"session:daemonid,omitempty"` @@ -104,8 +105,8 @@ type MetaTSType struct { BgActiveBorderColor string `json:"bg:activebordercolor,omitempty"` // frame:activebordercolor // for workspace - LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"` - LayoutWidgetsVisible *bool `json:"layout:widgetsvisible,omitempty"` + LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"` + LayoutWidgetsVisible *bool `json:"layout:widgetsvisible,omitempty"` // for tabs+waveai WaveAiPanelOpen bool `json:"waveai:panelopen,omitempty"` diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 09c1e2281a..002d2e1885 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -334,8 +334,8 @@ func applyWorkspaceDefaultsToBlocks(ctx context.Context, workspaceId string, tab } } if ws.DefaultCwd != "" && isFileBrowser { - meta[waveobj.MetaKey_File] = ws.DefaultCwd - meta["file:workspacecwd"] = true + meta[waveobj.MetaKey_File] = "" + meta[waveobj.MetaKey_FileCwd] = ws.DefaultCwd updated = true } if updated {