Update version to 0.1.3

Add daemon version check and restart logic

Add pane count to CenterToolbar

Add minSlots filter to PresetPicker
This commit is contained in:
2026-06-15 16:32:25 +07:00
parent ce6a8d56be
commit 0a67f401c4
8 changed files with 65 additions and 15 deletions
+1 -1
View File
@@ -3440,7 +3440,7 @@ dependencies = [
[[package]]
name = "spacesh-proto"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"bytes",
"serde",
+50 -2
View File
@@ -78,6 +78,13 @@ fn find_daemon() -> PathBuf {
PathBuf::from("spaceshd") // last resort: rely on PATH
}
/// The installed `spaceshd` binary's mtime as ms since the epoch (for staleness check).
fn daemon_bin_mtime_ms() -> Option<u64> {
let meta = std::fs::metadata(find_daemon()).ok()?;
let mtime = meta.modified().ok()?;
Some(mtime.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64)
}
async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
if let Ok(s) = UnixStream::connect(sock).await {
return Ok(s);
@@ -120,7 +127,7 @@ impl Bridge {
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?;
Ok(Self {
let bridge = Self {
next_id: AtomicU64::new(1),
app,
sock,
@@ -130,7 +137,48 @@ impl Bridge {
reader: Mutex::new(reader),
pending,
out_channels,
})
};
// The daemon outlives the GUI by design, so after an update the GUI may
// attach to a stale daemon — new features that need new daemon code then
// silently don't work. Restart it if it's out of date.
bridge.ensure_matching_daemon().await;
Ok(bridge)
}
/// Restart the running daemon if it predates the installed `spaceshd` binary
/// (or was built from a different commit). The bundled binary's mtime vs the
/// daemon's `started_at_ms` is the reliable signal: it catches every reinstall
/// even while developing dirty, where the git build id doesn't change.
async fn ensure_matching_daemon(&self) {
let Ok(reply) = self.request(Cmd::Health).await else { return };
let (daemon_build, started_at_ms) = match &reply {
Envelope::Res { data, .. } => (
data.get("build").and_then(|v| v.as_str()).map(str::to_string),
data.get("started_at_ms").and_then(|v| v.as_u64()),
),
_ => (None, None),
};
let gui_build = option_env!("SPACESH_BUILD").unwrap_or("dev");
let build_mismatch = gui_build != "dev"
&& daemon_build.as_deref().map(|b| b != gui_build).unwrap_or(false);
let binary_newer = match (daemon_bin_mtime_ms(), started_at_ms) {
(Some(bin_ms), Some(start_ms)) => bin_ms > start_ms,
_ => false,
};
if !build_mismatch && !binary_newer {
return;
}
// Ask the stale daemon to exit, wait for its socket to clear, then reconnect
// — which lazily spawns the fresh bundled daemon.
self.fire(Cmd::Shutdown).await;
for _ in 0..100 {
if UnixStream::connect(&self.sock).await.is_err() {
break;
}
tokio::time::sleep(Duration::from_millis(30)).await;
}
let seen = self.gen.load(Ordering::Acquire);
let _ = self.reconnect(seen).await;
}
/// Send a command without awaiting a reply or retrying. Used for Shutdown:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "xyz.spacesh.app",
"build": {
"frontendDist": "../dist",
+1 -1
View File
@@ -183,7 +183,7 @@ export function App() {
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
<CenterToolbar selected="" paneCount={leaves.length} onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
)}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active
+2 -2
View File
@@ -3,10 +3,10 @@ import { COLORS, FONT } from "./theme";
import { PresetPicker } from "./PresetPicker";
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */
export function CenterToolbar({ selected, onSelect, onOpenSearch }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void }) {
export function CenterToolbar({ selected, onSelect, onOpenSearch, paneCount }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void; paneCount: number }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<PresetPicker selected={selected} onSelect={onSelect} />
<PresetPicker selected={selected} onSelect={onSelect} minSlots={paneCount} />
<div style={{ flex: 1 }} />
<div
title="Search scrollback"
+4 -2
View File
@@ -13,10 +13,12 @@ export const PRESETS: { id: string; label: string; slots: number }[] = [
import { COLORS, FONT } from "./theme";
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
// `minSlots` hides presets smaller than the current pane count — applying a preset
// only ever ADDS panes (never destroys running ones); shrink by closing panels.
export function PresetPicker({ selected, onSelect, minSlots = 0 }: { selected: string; onSelect: (id: string) => void; minSlots?: number }) {
return (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{PRESETS.map((p) => {
{PRESETS.filter((p) => p.slots >= minSlots).map((p) => {
const on = p.id === selected;
return (
<button key={p.id} onClick={() => onSelect(p.id)}