Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/test-conn/testutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
37 changes: 36 additions & 1 deletion frontend/app/store/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,45 @@ 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<string, any> = 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: "", "file:cwd": workspace.defaultcwd };
}
if (nextMeta === meta) {
return blockDef;
}
return { ...blockDef, meta: nextMeta };
}

async function createBlockSplitHorizontally(
blockDef: BlockDef,
targetBlockId: string,
position: "before" | "after"
): Promise<string> {
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) {
Expand All @@ -389,6 +421,7 @@ async function createBlockSplitVertically(
): Promise<string> {
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) {
Expand All @@ -408,6 +441,7 @@ async function createBlockSplitVertically(
async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = false): Promise<string> {
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);
Expand All @@ -426,6 +460,7 @@ async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = fa
async function replaceBlock(blockId: string, blockDef: BlockDef, focus: boolean): Promise<string> {
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));
Expand Down Expand Up @@ -685,7 +720,6 @@ export {
getBlockComponentModel,
getBlockMetaKeyAtom,
getBlockTermDurableAtom,
getTabMetaKeyAtom,
getConfigBackgroundAtom,
getConnConfigKeyAtom,
getConnStatusAtom,
Expand All @@ -697,6 +731,7 @@ export {
getOverrideConfigAtom,
getSettingsKeyAtom,
getSettingsPrefixAtom,
getTabMetaKeyAtom,
getUserName,
globalPrimaryTabStartup,
globalStore,
Expand Down
12 changes: 12 additions & 0 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Block>(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;
}

Expand Down
7 changes: 6 additions & 1 deletion frontend/app/store/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ export class WorkspaceServiceType {
return callBackendService(this?.waveEnv, "workspace", "GetColors", Array.from(arguments))
}

// @returns connectionNames
GetConnectionNames(): Promise<string[]> {
return callBackendService(this?.waveEnv, "workspace", "GetConnectionNames", Array.from(arguments))
}

// @returns icons
GetIcons(): Promise<string[]> {
return callBackendService(this?.waveEnv, "workspace", "GetIcons", Array.from(arguments))
Expand All @@ -207,7 +212,7 @@ export class WorkspaceServiceType {
}

// @returns object updates
UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise<void> {
UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, defaultConnName: string, defaultCwd: string, applyDefaults: boolean): Promise<void> {
return callBackendService(this?.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments))
}
}
Expand Down
83 changes: 83 additions & 0 deletions frontend/app/tab/workspaceconnectionselector.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="connection-selector" ref={ref}>
<div className="connection-input-wrapper">
<Input
className="connection-input py-[3px]"
value={value}
onChange={onChange}
placeholder="Default connection"
onFocus={() => setOpen(true)}
/>
<i
className={clsx("fa-sharp fa-solid fa-chevron-down dropdown-arrow", { open })}
onClick={() => setOpen(!open)}
/>
</div>
{open && (
<OverlayScrollbarsComponent
className="connection-dropdown"
options={{ scrollbars: { autoHide: "leave" } }}
>
{items.map((item, idx) => {
if ("divider" in item) {
return <div key={idx} className="dropdown-divider" />;
}
return (
<div
key={item.value}
className={clsx("dropdown-item", { selected: item.value === value })}
onClick={() => handleSelect(item.value)}
>
{item.label}
</div>
);
})}
</OverlayScrollbarsComponent>
)}
</div>
);
};

export const WorkspaceConnectionSelector = memo(WorkspaceConnectionSelectorComponent);
95 changes: 81 additions & 14 deletions frontend/app/tab/workspaceeditor.scss
Original file line number Diff line number Diff line change
@@ -1,13 +1,81 @@
.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;
overscroll-behavior: contain;

.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;
Expand All @@ -21,7 +89,6 @@
cursor: pointer;
position: relative;

// Border offset outward
&:before {
content: "";
position: absolute;
Expand All @@ -34,16 +101,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;
Expand All @@ -60,11 +134,4 @@
}
}
}

.delete-ws-btn-wrapper {
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
}
}
Loading
Loading