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> {
|
||||
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::set_group,
|
||||
bridge::delete_group,
|
||||
bridge::focus,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running spacesh");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef } from "react";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import type { LayoutNode } from "./layoutTypes";
|
||||
import { StatusRing } from "./StatusRing";
|
||||
import type { LayoutNode, SurfaceState } from "./layoutTypes";
|
||||
import { setRatios, restartSurface } from "./socketBridge";
|
||||
|
||||
interface Props {
|
||||
@@ -8,16 +9,17 @@ interface Props {
|
||||
layout: LayoutNode | null;
|
||||
/** surface_id -> running flag, from the latest status/events. */
|
||||
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) {
|
||||
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) {
|
||||
const id = node.leaf.surface_id;
|
||||
if (running[id] === false) {
|
||||
@@ -28,7 +30,17 @@ function Node({ workspaceId, node, path, running }: { workspaceId: string; node:
|
||||
</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;
|
||||
@@ -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);
|
||||
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>
|
||||
))}
|
||||
</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({
|
||||
groups, workspaces, activeId, onSelect, onNew,
|
||||
@@ -19,7 +33,7 @@ export function Sidebar({
|
||||
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13,
|
||||
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>
|
||||
{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>
|
||||
|
||||
@@ -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 SurfaceState = "work" | "wait" | "done" | "error" | "idle";
|
||||
|
||||
export type LayoutNode =
|
||||
| { leaf: { surface_id: string } }
|
||||
| { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } };
|
||||
@@ -15,6 +17,7 @@ export interface SurfaceView {
|
||||
autostart: boolean;
|
||||
};
|
||||
running: boolean;
|
||||
state: SurfaceState;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
@@ -65,12 +65,20 @@ export async function getStatus(): Promise<WorkspaceStatus[]> {
|
||||
export type DaemonEvt =
|
||||
| { evt: "exit"; data: { surface_id: string; code: number } }
|
||||
| { 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> {
|
||||
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> {
|
||||
return listen(name, () => handler());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user