Compare commits

...

4 Commits

Author SHA1 Message Date
vasyansk 372dd7123a Update version to 0.1.6
Add Gitea package registry support

Add publish-dmg target for versioned DMG uploads

Update deploy-dmg to include Gitea publishing

Document Gitea token requirements in README
2026-06-15 16:52:24 +07:00
vasyansk 39bb8e5fee feat(app): close (X) on panel header + Close button on stopped overlay
Wires the existing closeSurfaceCmd into the panel header (red-on-hover X next
to zoom) and adds a Close button to the stopped overlay, so a panel — including
an empty/stopped one — can be dismissed instead of resumed/restarted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:47:43 +07:00
vasyansk d62628be8d fix(daemon): reseed id counter on restore + heal duplicate leaves
Root cause of the multi-focus/multi-search/linked-terminal bug: the in-memory
id counter resets to 0 each daemon start, but restore() never advanced it past
restored ids. After a restart new_surface_id() re-minted existing ids → the same
surface_id appeared twice in a layout tree (rendered as two panels sharing focus,
search bar, and output channel — one ends up blank). Session-persistence made
restarts routine, surfacing the latent bug.

- restore() now reseeds the counter to max(restored id)+1
- ops::dedupe_leaves heals an already-corrupted persisted tree on load

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:46:04 +07:00
vasyansk 3317b24d18 fix(daemon): gate NullSnapshotStore behind cfg(test) — silence release dead_code warning
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:34:10 +07:00
10 changed files with 179 additions and 12 deletions
Generated
+5 -5
View File
@@ -869,7 +869,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-cli" name = "spacesh-cli"
version = "0.1.3" version = "0.1.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -881,7 +881,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-core" name = "spacesh-core"
version = "0.1.3" version = "0.1.6"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"serde", "serde",
@@ -891,7 +891,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-proto" name = "spacesh-proto"
version = "0.1.3" version = "0.1.6"
dependencies = [ dependencies = [
"bytes", "bytes",
"serde", "serde",
@@ -903,7 +903,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-pty" name = "spacesh-pty"
version = "0.1.3" version = "0.1.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -913,7 +913,7 @@ dependencies = [
[[package]] [[package]]
name = "spaceshd" name = "spaceshd"
version = "0.1.3" version = "0.1.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "0.1.3" version = "0.1.6"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
+25 -1
View File
@@ -17,6 +17,12 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0)
REGISTRY ?= git.realmanual.ru REGISTRY ?= git.realmanual.ru
REPO ?= spacesh REPO ?= spacesh
# ---- Gitea generic package registry (versioned .dmg downloads) ----
GITEA_URL ?= https://git.realmanual.ru
GITEA_OWNER ?= realmanual
GITEA_PKG ?= spacesh
GITEA_TOKEN ?= # token with package:write; pass via env/CLI, never commit
# ---- Prod deploy (SSH) ---- # ---- Prod deploy (SSH) ----
SSH_HOST ?= 192.168.8.5 SSH_HOST ?= 192.168.8.5
SSH_USER ?= root SSH_USER ?= root
@@ -134,7 +140,7 @@ landing-push: landing-image ## tag & push the landing image to the registry
# ---- Prod deploy ---- # ---- Prod deploy ----
.PHONY: deploy-dmg .PHONY: deploy-dmg
deploy-dmg: dmg ## upload the .dmg + update manifest (latest.json) to the prod download dir deploy-dmg: dmg ## upload .dmg + manifest to prod, and publish the versioned .dmg to Gitea Packages
@VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \ @VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
printf '{"version":"%s","url":"https://spaceshell.ru/download/spacesh.dmg"}\n' "$$VER" > /tmp/spacesh-latest.json; \ printf '{"version":"%s","url":"https://spaceshell.ru/download/spacesh.dmg"}\n' "$$VER" > /tmp/spacesh-latest.json; \
echo "manifest version → $$VER" echo "manifest version → $$VER"
@@ -142,6 +148,24 @@ deploy-dmg: dmg ## upload the .dmg + update manifest (latest.json) to the prod d
scp $(SSH_OPTS) $(DMG_DIR)/*.dmg "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/spacesh.dmg" scp $(SSH_OPTS) $(DMG_DIR)/*.dmg "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/spacesh.dmg"
scp $(SSH_OPTS) /tmp/spacesh-latest.json "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/latest.json" scp $(SSH_OPTS) /tmp/spacesh-latest.json "$(SSH_USER)@$(SSH_HOST):$(SSH_REMOTE_DIR)/download/latest.json"
@echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg + latest.json" @echo "Uploaded → https://spaceshell.ru/download/spacesh.dmg + latest.json"
@$(MAKE) --no-print-directory _publish-dmg
.PHONY: publish-dmg
publish-dmg: dmg _publish-dmg ## build + publish the versioned .dmg to the Gitea package registry
# Internal: upload the most recently built .dmg to Gitea's generic registry under
# the current version. No build dependency, so deploy-dmg can call it without a
# second bump/rebuild. Skips (doesn't fail) when GITEA_TOKEN is unset.
.PHONY: _publish-dmg
_publish-dmg:
@if [ -z "$(GITEA_TOKEN)" ]; then echo "GITEA_TOKEN unset — skipping Gitea Packages publish"; exit 0; fi; \
VER=$$(node -p "require('./$(APP_DIR)/src-tauri/tauri.conf.json').version"); \
DMG=$$(ls -t $(DMG_DIR)/*.dmg 2>/dev/null | head -1); \
if [ -z "$$DMG" ]; then echo "no .dmg in $(DMG_DIR) — run make dmg first"; exit 1; fi; \
URL="$(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic/$(GITEA_PKG)/$$VER/spacesh-$$VER.dmg"; \
echo "Publishing $$DMG → $$URL"; \
curl --fail-with-body -sS -H "Authorization: token $(GITEA_TOKEN)" --upload-file "$$DMG" "$$URL" && \
echo "Published spacesh-$$VER.dmg to Gitea Packages ($(GITEA_OWNER)/$(GITEA_PKG)@$$VER)"
.PHONY: deploy-stack .PHONY: deploy-stack
deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push) deploy-stack: ## sync compose+proxy.conf to prod and pull/up (manual; CI does this on push)
+1 -1
View File
@@ -3440,7 +3440,7 @@ dependencies = [
[[package]] [[package]]
name = "spacesh-proto" name = "spacesh-proto"
version = "0.1.3" version = "0.1.6"
dependencies = [ dependencies = [
"bytes", "bytes",
"serde", "serde",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "spacesh", "productName": "spacesh",
"version": "0.1.3", "version": "0.1.6",
"identifier": "xyz.spacesh.app", "identifier": "xyz.spacesh.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+10 -2
View File
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Maximize2, Minimize2, RotateCw, GripVertical, Play } from "lucide-react"; import { Maximize2, Minimize2, RotateCw, GripVertical, Play, X } from "lucide-react";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
import { StatusRing } from "./StatusRing"; import { StatusRing } from "./StatusRing";
import { COLORS, FONT, STATE_COLOR } from "./theme"; import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface } from "./socketBridge"; import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface, closeSurfaceCmd } from "./socketBridge";
interface Props { interface Props {
workspaceId: string; workspaceId: string;
@@ -193,6 +193,10 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}> style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<RotateCw size={13} /> Restart fresh <RotateCw size={13} /> Restart fresh
</button> </button>
<button onClick={() => void closeSurfaceCmd(id)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<X size={13} /> Close
</button>
{zoomed === id && ( {zoomed === id && (
<button onClick={() => void setZoom(workspaceId, null)} <button onClick={() => void setZoom(workspaceId, null)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}> style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
@@ -228,6 +232,10 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} /> onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom" : <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />} onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
<X size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Close panel"
onMouseDown={(e) => { e.stopPropagation(); void closeSurfaceCmd(id); }}
onMouseEnter={(e) => { e.currentTarget.style.color = COLORS.stError; }}
onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
</div> </div>
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} font={font} palette={palette} /> <TerminalView key={id} surfaceId={id} font={font} palette={palette} />
+47
View File
@@ -72,6 +72,32 @@ pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option<LayoutNode> {
} }
} }
/// Drop duplicate leaves, keeping the first (left-to-right) occurrence of each
/// surface id; collapses now-single-child splits. Returns None if empty.
///
/// Heals a tree corrupted by a duplicate surface id (e.g. an id re-minted after
/// a daemon restart before the counter fix), which otherwise renders the same
/// panel twice and confuses focus/search/output routing keyed by surface id.
pub fn dedupe_leaves(root: LayoutNode) -> Option<LayoutNode> {
let mut seen = std::collections::HashSet::new();
dedupe(root, &mut seen)
}
fn dedupe(node: LayoutNode, seen: &mut std::collections::HashSet<SurfaceId>) -> Option<LayoutNode> {
match node {
LayoutNode::Leaf { surface_id } => {
if seen.insert(surface_id.clone()) { Some(LayoutNode::Leaf { surface_id }) } else { None }
}
LayoutNode::Split { orient, children, .. } => {
let kept: Vec<LayoutNode> = children.into_iter().filter_map(|c| dedupe(c, seen)).collect();
match kept.len() {
0 => None,
1 => Some(kept.into_iter().next().unwrap()),
n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }),
}
}
}
}
/// Set ratios on the split node addressed by `path` (child indices from root). /// Set ratios on the split node addressed by `path` (child indices from root).
/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the /// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the
/// path is invalid or the length does not match the node's child count. /// path is invalid or the length does not match the node's child count.
@@ -247,6 +273,27 @@ mod tests {
assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]); assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]);
} }
#[test]
fn dedupe_removes_duplicate_leaf_keeping_first() {
// s_1 appears twice (the production corruption): heal to one occurrence.
let root = LayoutNode::Split {
orient: Orient::H, ratios: vec![1.0/3.0; 3],
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_1"))],
};
let healed = dedupe_leaves(root).unwrap();
assert_eq!(leaves(&healed), vec![sid("s_0"), sid("s_1")]);
}
#[test]
fn dedupe_clean_tree_is_unchanged() {
let root = LayoutNode::Split {
orient: Orient::H, ratios: vec![0.5, 0.5],
children: vec![LayoutNode::leaf(sid("s_0")), LayoutNode::leaf(sid("s_1"))],
};
let out = dedupe_leaves(root.clone()).unwrap();
assert_eq!(leaves(&out), vec![sid("s_0"), sid("s_1")]);
}
#[test] #[test]
fn move_onto_self_is_noop() { fn move_onto_self_is_noop() {
let root = LayoutNode::leaf(sid("s_1")); let root = LayoutNode::leaf(sid("s_1"));
+73
View File
@@ -23,6 +23,12 @@ pub struct Registry {
states: HashMap<SurfaceId, SurfaceState>, states: HashMap<SurfaceId, SurfaceState>,
} }
/// Parse the hex numeric suffix of an id (`"s_1f"` → `0x1f`). None if malformed.
/// All ids are minted as `format!("{prefix}_{n:x}")`, so the suffix is hex.
fn id_num(id: &str) -> Option<u64> {
id.rsplit_once('_').and_then(|(_, hex)| u64::from_str_radix(hex, 16).ok())
}
impl Registry { impl Registry {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
@@ -193,8 +199,29 @@ impl Registry {
self.by_path.clear(); self.by_path.clear();
self.live.clear(); self.live.clear();
self.states.clear(); self.states.clear();
// Advance the id counter past every restored id. The in-memory counter
// resets to 0 on each daemon start; without this reseed, after a restart
// `new_surface_id()` re-mints ids that already exist — producing duplicate
// leaves in a workspace tree (same panel rendered twice, focus/search/
// output routing keyed by surface id all break) and cross-workspace id
// collisions.
let mut max_id = 0u64;
for gid in self.groups.keys() {
if let Some(n) = id_num(&gid.0) { max_id = max_id.max(n + 1); }
}
for w in &state.workspaces {
if let Some(n) = id_num(&w.id.0) { max_id = max_id.max(n + 1); }
for sid in w.surfaces.keys() {
if let Some(n) = id_num(&sid.0) { max_id = max_id.max(n + 1); }
}
}
self.counter.store(max_id, Ordering::Relaxed);
for w in state.workspaces { for w in state.workspaces {
let mut w = w; let mut w = w;
// Heal a tree already corrupted by a duplicate leaf (pre-fix state).
w.layout = w.layout.take().and_then(spacesh_core::ops::dedupe_leaves);
if let Some(z) = &w.zoomed { if let Some(z) = &w.zoomed {
if !w.surfaces.contains_key(z) { w.zoomed = None; } if !w.surfaces.contains_key(z) { w.zoomed = None; }
} }
@@ -252,6 +279,52 @@ mod tests {
assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed
} }
#[test]
fn restore_advances_counter_past_existing_ids() {
// After a daemon restart the counter must not re-mint a restored id.
let mut r = Registry::new();
let mut surfaces = HashMap::new();
surfaces.insert(SurfaceId("s_5".into()), spec());
let st = PersistState {
version: 1, groups: vec![],
workspaces: vec![Workspace {
id: WorkspaceId("w_2".into()), path: "/p".into(), name: "p".into(),
group_id: None, order: 0, unread: false, pinned: false,
layout: Some(LN::leaf(SurfaceId("s_5".into()))), zoomed: None, surfaces,
}],
};
r.restore(st);
// max restored id is s_5 (hex 5) → next minted must be s_6, no collision.
assert_eq!(r.new_surface_id(), SurfaceId("s_6".into()));
}
#[test]
fn restore_heals_duplicate_leaf() {
// A persisted tree with s_1 twice (the production corruption) heals to one.
let mut r = Registry::new();
let mut surfaces = HashMap::new();
surfaces.insert(SurfaceId("s_0".into()), spec());
surfaces.insert(SurfaceId("s_1".into()), spec());
let tree = LN::Split {
orient: Orient::H, ratios: vec![1.0 / 3.0; 3],
children: vec![LN::leaf(SurfaceId("s_0".into())), LN::leaf(SurfaceId("s_1".into())), LN::leaf(SurfaceId("s_1".into()))],
};
let st = PersistState {
version: 1, groups: vec![],
workspaces: vec![Workspace {
id: WorkspaceId("w_0".into()), path: "/p".into(), name: "p".into(),
group_id: None, order: 0, unread: false, pinned: false,
layout: Some(tree), zoomed: None, surfaces,
}],
};
r.restore(st);
let w = r.workspace(&WorkspaceId("w_0".into())).unwrap();
assert_eq!(
spacesh_core::ops::leaves(w.layout.as_ref().unwrap()),
vec![SurfaceId("s_0".into()), SurfaceId("s_1".into())]
);
}
#[test] #[test]
fn restore_round_trips_through_persist_state() { fn restore_round_trips_through_persist_state() {
let mut r = Registry::new(); let mut r = Registry::new();
+4 -1
View File
@@ -17,8 +17,11 @@ pub enum SnapshotMsg {
Remove(SurfaceId), Remove(SurfaceId),
} }
/// A no-op store for tests and contexts that do not persist snapshots. /// A no-op store for tests that do not persist snapshots. Test-only — gated so
/// release builds don't warn about an unconstructed struct.
#[cfg(test)]
pub struct NullSnapshotStore; pub struct NullSnapshotStore;
#[cfg(test)]
impl SnapshotStore for NullSnapshotStore { impl SnapshotStore for NullSnapshotStore {
fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {} fn save(&self, _sid: &SurfaceId, _snap: &Snapshot) {}
fn load(&self, _sid: &SurfaceId) -> Option<Snapshot> { None } fn load(&self, _sid: &SurfaceId) -> Option<Snapshot> { None }
+12
View File
@@ -25,6 +25,18 @@ mkdir -p $SSH_REMOTE_DIR/download
server, then `docker compose pull && up -d`. server, then `docker compose pull && up -d`.
- **DMG** → built on macOS, uploaded by hand: `make deploy-dmg` - **DMG** → built on macOS, uploaded by hand: `make deploy-dmg`
(sets the stable `download/spacesh.dmg`). Tauri can't build a macOS bundle in CI. (sets the stable `download/spacesh.dmg`). Tauri can't build a macOS bundle in CI.
The same command also publishes a **versioned** copy to the Gitea package
registry (`Packages` tab) when `GITEA_TOKEN` is set:
```bash
GITEA_TOKEN=<token-with-package:write> make deploy-dmg # server + Gitea Packages
make publish-dmg GITEA_TOKEN=<token> # build + Gitea Packages only
```
Published at `{GITEA_URL}/api/packages/{GITEA_OWNER}/generic/spacesh/<version>/spacesh-<version>.dmg`
(override `GITEA_URL`/`GITEA_OWNER`/`GITEA_PKG` if needed). Version comes from
`tauri.conf.json`, bumped on every `make dmg`. Without `GITEA_TOKEN` the publish
step is skipped (server copy still happens).
## Gitea secrets required ## Gitea secrets required