feat(app): status rings on panels + sidebar aggregate badge from state events
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -255,3 +255,8 @@ pub async fn set_group(state: BridgeState<'_>, group_id: String, name: Option<St
|
|||||||
pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result<Value, String> {
|
pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result<Value, String> {
|
||||||
data_of(state.request(Cmd::DeleteGroup { group_id: GroupId(group_id) }).await.map_err(|e| e.to_string())?)
|
data_of(state.request(Cmd::DeleteGroup { group_id: GroupId(group_id) }).await.map_err(|e| e.to_string())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn focus(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
|
||||||
|
data_of(state.request(Cmd::Focus { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub fn run() {
|
|||||||
bridge::create_group,
|
bridge::create_group,
|
||||||
bridge::set_group,
|
bridge::set_group,
|
||||||
bridge::delete_group,
|
bridge::delete_group,
|
||||||
|
bridge::focus,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running spacesh");
|
.expect("error while running spacesh");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
import type { LayoutNode } from "./layoutTypes";
|
import { StatusRing } from "./StatusRing";
|
||||||
|
import type { LayoutNode, SurfaceState } from "./layoutTypes";
|
||||||
import { setRatios, restartSurface } from "./socketBridge";
|
import { setRatios, restartSurface } from "./socketBridge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,16 +9,17 @@ interface Props {
|
|||||||
layout: LayoutNode | null;
|
layout: LayoutNode | null;
|
||||||
/** surface_id -> running flag, from the latest status/events. */
|
/** surface_id -> running flag, from the latest status/events. */
|
||||||
running: Record<string, boolean>;
|
running: Record<string, boolean>;
|
||||||
|
states: Record<string, SurfaceState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutEngine({ workspaceId, layout, running }: Props) {
|
export function LayoutEngine({ workspaceId, layout, running, states }: Props) {
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
return <div style={{ color: "#666", padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
return <div style={{ color: "#666", padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
||||||
}
|
}
|
||||||
return <Node workspaceId={workspaceId} node={layout} path={[]} running={running} />;
|
return <Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Node({ workspaceId, node, path, running }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record<string, boolean> }) {
|
function Node({ workspaceId, node, path, running, states }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record<string, boolean>; states: Record<string, SurfaceState> }) {
|
||||||
if ("leaf" in node) {
|
if ("leaf" in node) {
|
||||||
const id = node.leaf.surface_id;
|
const id = node.leaf.surface_id;
|
||||||
if (running[id] === false) {
|
if (running[id] === false) {
|
||||||
@@ -28,7 +30,17 @@ function Node({ workspaceId, node, path, running }: { workspaceId: string; node:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <TerminalView key={id} surfaceId={id} />;
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "3px 8px", background: "#0A0D12", borderBottom: "1px solid #232A33" }}>
|
||||||
|
<StatusRing state={states[id] ?? "idle"} running={true} />
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{id}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<TerminalView key={id} surfaceId={id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { orient, ratios, children } = node.split;
|
const { orient, ratios, children } = node.split;
|
||||||
@@ -43,7 +55,7 @@ function Node({ workspaceId, node, path, running }: { workspaceId: string; node:
|
|||||||
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
|
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
|
||||||
void setRatios(workspaceId, path, next);
|
void setRatios(workspaceId, path, next);
|
||||||
}}>
|
}}>
|
||||||
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} />
|
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} />
|
||||||
</Pane>
|
</Pane>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-2
@@ -1,4 +1,18 @@
|
|||||||
import type { Group, WorkspaceView } from "./layoutTypes";
|
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||||
|
|
||||||
|
const RING: Record<SurfaceState | "stopped", string> = {
|
||||||
|
error: "#F4544E", wait: "#F2B84B", work: "#4C8DFF", done: "#3FB950", idle: "#5A6573", stopped: "#5A6573",
|
||||||
|
};
|
||||||
|
|
||||||
|
function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
|
||||||
|
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
|
||||||
|
const running = Object.values(w.surfaces).filter((s) => s.running);
|
||||||
|
if (running.length === 0) return "stopped";
|
||||||
|
for (const st of order) {
|
||||||
|
if (running.some((s) => s.state === st)) return st;
|
||||||
|
}
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
groups, workspaces, activeId, onSelect, onNew,
|
groups, workspaces, activeId, onSelect, onNew,
|
||||||
@@ -19,7 +33,7 @@ export function Sidebar({
|
|||||||
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13,
|
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13,
|
||||||
color: w.id === activeId ? "#E6EDF3" : "#8B97A6",
|
color: w.id === activeId ? "#E6EDF3" : "#8B97A6",
|
||||||
}}>
|
}}>
|
||||||
<span style={{ width: 10, height: 10, borderRadius: "50%", border: "2px solid #5A6573" }} />
|
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${RING[aggregate(w)]}`, boxSizing: "border-box" }} />
|
||||||
<span style={{ flex: 1 }}>{w.name}</span>
|
<span style={{ flex: 1 }}>{w.name}</span>
|
||||||
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4C8DFF" }} />}
|
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4C8DFF" }} />}
|
||||||
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#5A6573" }}>{Object.keys(w.surfaces).length}</span>
|
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#5A6573" }}>{Object.keys(w.surfaces).length}</span>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { SurfaceState } from "./layoutTypes";
|
||||||
|
|
||||||
|
const COLOR: Record<SurfaceState, string> = {
|
||||||
|
work: "#4C8DFF",
|
||||||
|
wait: "#F2B84B",
|
||||||
|
done: "#3FB950",
|
||||||
|
error: "#F4544E",
|
||||||
|
idle: "#5A6573",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusRing({ state, running }: { state: SurfaceState; running: boolean }) {
|
||||||
|
const color = running ? COLOR[state] : "#5A6573";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={running ? state : "stopped"}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: `2px solid ${color}`,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
opacity: running ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
export type Orient = "h" | "v";
|
export type Orient = "h" | "v";
|
||||||
|
|
||||||
|
export type SurfaceState = "work" | "wait" | "done" | "error" | "idle";
|
||||||
|
|
||||||
export type LayoutNode =
|
export type LayoutNode =
|
||||||
| { leaf: { surface_id: string } }
|
| { leaf: { surface_id: string } }
|
||||||
| { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } };
|
| { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } };
|
||||||
@@ -15,6 +17,7 @@ export interface SurfaceView {
|
|||||||
autostart: boolean;
|
autostart: boolean;
|
||||||
};
|
};
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
state: SurfaceState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|||||||
@@ -65,12 +65,20 @@ export async function getStatus(): Promise<WorkspaceStatus[]> {
|
|||||||
export type DaemonEvt =
|
export type DaemonEvt =
|
||||||
| { evt: "exit"; data: { surface_id: string; code: number } }
|
| { evt: "exit"; data: { surface_id: string; code: number } }
|
||||||
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
||||||
| { evt: "surface_closed"; data: { surface_id: string } };
|
| { evt: "surface_closed"; data: { surface_id: string } }
|
||||||
|
| { evt: "state"; data: { surface_id: string; state: import("./layoutTypes").SurfaceState } }
|
||||||
|
| { evt: "layout_changed"; data: { workspace_id: string } }
|
||||||
|
| { evt: "workspace_changed"; data: unknown }
|
||||||
|
| { evt: "groups_changed"; data: unknown };
|
||||||
|
|
||||||
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
|
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
|
||||||
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
|
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function focusSurface(surfaceId: string): Promise<void> {
|
||||||
|
await invoke("focus", { surfaceId });
|
||||||
|
}
|
||||||
|
|
||||||
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
|
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
|
||||||
return listen(name, () => handler());
|
return listen(name, () => handler());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user