Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
7b27f79
feat(desktop): add the core crate and a fixture-driven app window (HA…
roodboi Nov 14, 2026
60d0e01
fix(desktop): read native projects with the native backend and treat …
roodboi Nov 14, 2026
4643e7b
fix(desktop): accept null collections, bound pipe draining by the tim…
roodboi Nov 14, 2026
bc7f004
fix(desktop): show exit-0 services as exited with unknown role, and l…
roodboi Nov 14, 2026
cc2677d
fix(desktop): keep startup failure detail, make Open single-flight, h…
roodboi Nov 14, 2026
9d32d82
fix(desktop): validate the CLI projects payload, show native services…
roodboi Nov 14, 2026
48fb07c
fix(desktop): trust hackd only from a private directory and a same-us…
roodboi Nov 14, 2026
7bd5b48
docs(desktop): correct the native view-only note
roodboi Nov 14, 2026
a1144a7
fix(desktop): clear a failed start only on evidence, and treat any fa…
roodboi Nov 14, 2026
0518c95
fix(desktop): scroll the sidebar project list between the fixed heade…
roodboi Nov 14, 2026
cec136c
Merge remote-tracking branch 'origin/next' into feat/desktop-app-core
roodboi Nov 14, 2026
c814cb0
docs(desktop): render all 15 fixture states in the screenshot boards
roodboi Nov 14, 2026
a6c423a
fix(desktop): do not count never-created services (inactive profiles)…
roodboi Nov 14, 2026
dab34b4
fix(desktop): scroll the detail content below a fixed header
roodboi Nov 14, 2026
408fb6e
fix(desktop): run hack by absolute path found on absolute PATH entrie…
roodboi Nov 14, 2026
127ac3c
fix(desktop): act only on project roots the user owns
roodboi Nov 14, 2026
1608e4a
fix(desktop): validate the project root's path chain, marker and conf…
roodboi Nov 14, 2026
89a1af9
fix(desktop): give CLI children a PATH with only absolute entries
roodboi Nov 14, 2026
b7ee630
fix(desktop): require the CLI to select exactly the validated root, a…
roodboi Nov 14, 2026
cce493d
fix(desktop): never hand children an empty PATH
roodboi Nov 14, 2026
7230198
fix(desktop): show Doctor as unknown until it has run
roodboi Nov 14, 2026
630d6cb
fix(desktop): on timeout, SIGTERM the CLI's group and wait before SIG…
roodboi Nov 14, 2026
f7af68e
fix(desktop): let the CLI own lifecycle timeouts instead of cutting t…
roodboi Nov 14, 2026
c1dde2b
fix(desktop): never terminate lifecycle commands from the app; warn a…
roodboi Nov 14, 2026
c24bba2
fix(desktop): build and test on macOS, and add a headless live-state …
roodboi Nov 14, 2026
5bf507b
Merge remote-tracking branch 'origin/next' into feat/desktop-app-core
roodboi Nov 14, 2026
cbc43ef
test(desktop): never count a missing /proc as proof a process exited
roodboi Nov 14, 2026
263d04e
fix(desktop): drain CLI output pipes within bounded memory
roodboi Nov 14, 2026
efd92f8
fix(desktop): retry interrupted pipe reads and report other read fail…
roodboi Nov 14, 2026
db1d4c6
Merge remote-tracking branch 'origin/next' into HEAD
roodboi Nov 14, 2026
b341b32
fix(desktop): align the minimum toolchain and isolate state-reader tests
roodboi Nov 14, 2026
f1b89dd
feat(desktop): add names-only environment tab
roodboi Nov 14, 2026
71376d8
fix(desktop): preserve failure evidence and refuse registry-writing p…
roodboi Nov 14, 2026
63a263f
Merge commit '71376d86baedf6bfb790c5676d039ee3716b11ff' into feat/des…
roodboi Nov 14, 2026
72ae2a5
fix(desktop): mark stale observations unknown and offer daemon recovery
roodboi Nov 14, 2026
0a22d10
Merge commit '72ae2a583496db4ce2449efc48b49749ed1fc2ba' into feat/des…
roodboi Nov 14, 2026
ed1b336
fix(desktop): distinguish empty environment scopes from absent layers
roodboi Nov 14, 2026
d39223e
fix(desktop): detect failed exits across every service replica
roodboi Nov 14, 2026
e43268f
Merge remote-tracking branch 'origin/feat/desktop-app-core' into feat…
roodboi Nov 14, 2026
9de250c
fix(desktop): bind env reads to the selected project root
roodboi Nov 14, 2026
e539505
Merge remote-tracking branch 'origin/next' into feat/desktop-app-env
roodboi Nov 14, 2026
5f67467
fix(desktop): preserve core warmups and tail invalidation after merge
roodboi Nov 14, 2026
609d5cc
Merge remote-tracking branch 'origin/next' into feat/desktop-app-env
roodboi Nov 14, 2026
bb70d35
fix(desktop): explain environment readability without internal IDs
roodboi Nov 14, 2026
fd76046
Merge remote-tracking branch 'origin/next' into feat/desktop-app-env
roodboi Nov 14, 2026
08db90a
docs(desktop): normalize merged readme ending
roodboi Nov 14, 2026
2721130
merge: integrate next into the Env tab
roodboi Nov 15, 2026
7bf82d9
merge: integrate next (#835, #831) into the Env tab
roodboi Nov 15, 2026
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
53 changes: 47 additions & 6 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Hack desktop app (preview)

Unsupported preview of a minimal native window for Hack (HACK-2214): every
Unsupported preview of a minimal native window for Hack: every
project, its state and services, and Start / Stop / Restart / Open. It is a view
over the `hack` CLI and the `hackd` socket; it keeps no state beyond window
layout, holds no credentials and runs `hack` as you for every action. Not part
Expand Down Expand Up @@ -49,10 +49,9 @@ cargo run -p hack-desktop-core --bin hack-desktop-state -- --project <name>
```

`hack-desktop-state` reads exactly what the window reads: hackd when its version
matches the CLI, plus native `hack ps` for native projects. Today's CLI listing
auto-registers runtime projects and writes the registry, so it is never used as
a fallback. Without a matching daemon the reader reports Unknown / needs R18
(observe-only projects), rather than writing or claiming no projects exist.
matches the CLI, plus native `hack ps` for native projects. This app does not
use a CLI project-list fallback. Without a matching daemon the reader reports
Unknown, rather than writing or claiming no projects exist.
The window retains stale names with Unknown states and disables project actions.
It offers **Start hackd** when unreachable or **Restart hackd** for a version
mismatch. Only a fresh matching snapshot restores observed states.
Expand Down Expand Up @@ -88,10 +87,52 @@ degraded / stopping, failed-start, needs-attention):

## What v0 does not do yet

Logs, Env and Activity tabs, CPU and memory, Doctor view, onboarding, always-on,
Logs and Activity tabs, CPU and memory, Doctor view, onboarding, always-on,
daemon actions and upgrades come in later units. The design and the CLI/daemon
requests they need are in `DESIGN.md` (PR #800).

## Env tab (names only)

Env reads `hack env list --json --path <root>` on tab open, project selection,
and **Check again**, with a 10-second deadline. Native projects select
`HACK_RUNTIME_BACKEND=native`. There is no polling or disk state for Env.
A bare report on exit 1 is still a report. Missing required names appear first
in red; both legacy scope arrays and canonical scope strings are supported.
An explicit empty `vars` array shows “This project has no env layers” only
when the report has no configured files/scopes or selected scope. Canonical
reports label the selected scope (global by default); an empty scope is not
project-wide absence. Other service scopes are not included in this listing.
Malformed output shows “Could not read environment metadata”.

The model retains only name, layer/source, scope, storage kind, set and required
metadata. It discards unknown fields, including values/materialized data, and
never includes raw output in errors. No editing, value reads, reveal, copy or
export is offered. The locked state shows `hack doctor` with **Copy command**.
Missing/unreadable `key_state` shows a lock. Absent key state means readability
is unknown, never inferred healthy from `has_value`: Set indicates storage,
not whether a value can be read.

Fixtures: `env-loading`, `env-error`, `env-normal`, `env-missing`, `env-locked`,
`env-empty`. Each renders offscreen in both themes through `--screenshots`.
To bound rendering to one state, combine `--fixtures env-normal` with
`--screenshots <output-dir>`.
Screenshots for this slice: [normal light](screenshots/env-normal-light.png),
[normal dark](screenshots/env-normal-dark.png),
[missing](screenshots/env-missing-light.png), [locked](screenshots/env-locked-dark.png).

This Env change is stacked on `feat/desktop-app-core` for focused review;
the completed desktop stack ultimately targets `next`.

Env reads refuse missing project markers and nested `.dev` targets shadowed by
an ancestor `.hack` project, using the CLI's discovery precedence. The adapter
canonicalizes the selected root and checks markers before and after the read;
concurrent filesystem edits are not an atomic fence. Empty legacy scope arrays
are shown as `global`. macOS keychain prompting remains unverified. Rapid tab
changes may launch overlapping bounded reads; stale results are discarded, but
in-flight reads are not deduplicated.

## Process-cleanup tests

The supported desktop test command uses the repository-pinned nextest release
(installed by `scripts/install-cargo-nextest.sh` at the repository root), with
zero retries and one process per test. Raw threaded `cargo test --workspace` is
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fn main() {
let args = parse_args();
#[cfg(feature = "screenshots")]
if let Some(dir) = &args.screenshots {
screenshots::run(dir);
screenshots::run(dir, args.fixture);
return;
}
#[cfg(not(feature = "screenshots"))]
Expand All @@ -114,6 +114,9 @@ fn main() {
let mut app = view::HackApp::new(source, fixture.is_none(), cx);
if let Some(f) = fixture {
app.select(fixtures::selected(f));
if f.name().starts_with("env-") {
app.show_env_fixture();
}
if matches!(f, Fixture::DaemonDown | Fixture::DaemonMismatch) {
app.simulate_daemon_unavailable(f == Fixture::DaemonMismatch);
}
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/app/src/screenshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use hack_desktop_core::fixtures::{self, Fixture};

use crate::view::HackApp;

pub fn run(dir: &Path) {
pub fn run(dir: &Path, fixture: Option<Fixture>) {
std::fs::create_dir_all(dir).expect("create screenshot dir");
let mut cx = HeadlessAppContext::with_platform(
gpui_kit::platform::current_platform(true).text_system(),
Expand All @@ -23,7 +23,10 @@ pub fn run(dir: &Path) {
);
cx.update(gpui_kit::init);
for mode in [ThemeMode::Light, ThemeMode::Dark] {
for f in Fixture::ALL {
for f in Fixture::ALL
.into_iter()
.filter(|f| fixture.is_none_or(|selected| selected == *f))
{
cx.update(|cx| Theme::change(mode, None, cx));
let opts = WindowOptions {
focus: false,
Expand All @@ -36,6 +39,9 @@ pub fn run(dir: &Path) {
cx.new(|cx| {
let mut app = HackApp::new(crate::source_for(Some(f)), false, cx);
app.select(fixtures::selected(f));
if f.name().starts_with("env-") {
app.show_env_fixture();
}
if matches!(f, Fixture::DaemonDown | Fixture::DaemonMismatch) {
app.simulate_daemon_unavailable(f == Fixture::DaemonMismatch);
}
Expand Down
181 changes: 174 additions & 7 deletions apps/desktop/app/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub struct HackApp {
model: AppModel,
source: Arc<dyn Source>,
tab: usize,
env: hack_desktop_core::env::EnvModel,
tail: Result<Vec<LogEntry>, String>,
follow: bool,
live: bool,
Expand All @@ -49,6 +50,7 @@ impl HackApp {
model: AppModel::new(),
source,
tab: 0,
env: Default::default(),
tail: Ok(Vec::new()),
follow: true,
live,
Expand Down Expand Up @@ -78,6 +80,42 @@ impl HackApp {
pub fn select(&mut self, name: &str) {
self.model.apply(Event::Select(name.to_owned()));
self.refresh_now();
self.env.begin();
}

/// Select the Env fixture without opening a window.
pub fn show_env_fixture(&mut self) {
self.tab = 2;
let generation = self.env.begin();
if let Some((root, native)) = self.selected_root() {
self.env.finish(generation, self.source.env(&root, native));
}
}

fn read_env(&mut self, cx: &mut Context<Self>) {
let generation = self.env.begin();
let Some((root, native)) = self.selected_root() else {
return;
};
if !self.live {
self.env.finish(generation, self.source.env(&root, native));
return;
}
let source = self.source.clone();
let requested = root.clone();
cx.spawn(async move |this, cx| {
let view = cx
.background_executor()
.spawn(async move { source.env(&root, native) })
.await;
let _ = this.update(cx, |this, cx| {
if this.tab == 2 && this.selected_root() == Some((requested, native)) {
this.env.finish(generation, view);
cx.notify();
}
});
})
.detach();
}

/// Fixtures: mark an action as in flight without running it.
Expand Down Expand Up @@ -135,6 +173,10 @@ impl HackApp {
}
if previous != this.selected_root() {
this.tail = Ok(Vec::new());
this.env.begin();
if this.tab == 2 {
this.read_env(cx);
}
}
cx.notify();
this.follow.then(|| this.selected_root()).flatten()
Expand Down Expand Up @@ -223,10 +265,16 @@ impl HackApp {
.await;
let _ = this.update(cx, |this, cx| {
let previous = this.selected_root();
if this.model.finish_refresh(token, snap) && previous != this.selected_root() {
this.tail = Ok(Vec::new());
if this.model.finish_refresh(token, snap) {
if previous != this.selected_root() {
this.tail = Ok(Vec::new());
this.env.begin();
if this.tab == 2 {
this.read_env(cx);
}
}
cx.notify();
}
cx.notify();
});
})
.detach();
Expand Down Expand Up @@ -410,6 +458,10 @@ impl HackApp {
})
.on_click(cx.listener(move |this, _, _, cx| {
this.model.apply(Event::Select(name.clone()));
this.env.begin();
if this.tab == 2 {
this.read_env(cx);
}
this.tail = Ok(Vec::new());
if !this.live {
this.refresh_now();
Expand Down Expand Up @@ -520,7 +572,7 @@ impl HackApp {
.checked(d.always_on)
.small()
.disabled(true)
.tooltip("Needs hack autostart (HACK-2213)"),
.tooltip("Always-on controls are not available yet"),
),
)
.children(reason_line(&d.state, d.last_error.as_deref(), cx))
Expand All @@ -539,10 +591,14 @@ impl HackApp {
.selected_index(self.tab)
.child(Tab::new().label("Services"))
.child(Tab::new().label("Logs").disabled(true))
.child(Tab::new().label("Env").disabled(true))
.child(Tab::new().label("Env"))
.child(Tab::new().label("Activity").disabled(true))
.on_click(cx.listener(|this, ix: &usize, _, cx| {
this.env.begin();
this.tab = *ix;
if *ix == 2 {
this.read_env(cx);
}
cx.notify();
}));
// The header and tabs stay fixed; the tab content scrolls within the
Expand All @@ -559,11 +615,122 @@ impl HackApp {
.flex_1()
.min_h_0()
.overflow_y_scroll()
.child(services_table(&d.services, cx))
.child(self.log_tail(cx)),
.child(if self.tab == 2 {
self.env_tab(cx).into_any_element()
} else {
v_flex()
.child(services_table(&d.services, cx))
.child(self.log_tail(cx))
.into_any_element()
}),
)
}

fn env_tab(&self, cx: &mut Context<Self>) -> impl IntoElement {
use hack_desktop_core::env::{EnvView, KeyState};
let t = cx.theme().clone();
let mut body = v_flex().p_5().gap_3().text_sm().child(
h_flex().gap_3().child("Environment · names only").child(
Button::new("env-check")
.label("Check again")
.small()
.on_click(cx.listener(|this, _, _, cx| {
this.read_env(cx);
cx.notify();
})),
),
);
match &self.env.view {
EnvView::Loading => body = body.child("Reading environment metadata…"),
EnvView::Unreadable => {
body = body
.child("Could not read environment metadata. Check again or run hack doctor.")
}
EnvView::Report(report) => {
if let Some(scope) = &report.selected_scope {
body = body.child(format!(
"Scope: {scope} · other scopes are not included in this listing"
));
}
body = body.child(match report.key_state {
KeyState::Missing | KeyState::Unreadable => {
"🔒 Names are shown; stored values cannot be read."
}
KeyState::Unknown => "Key readability is unknown.",
KeyState::Ok => "Key is readable",
});
body = body.child(
"Set means a value is stored. It does not confirm that the value can be read.",
);
if matches!(report.key_state, KeyState::Missing | KeyState::Unreadable) {
body = body.child(
h_flex()
.gap_3()
.child(
div()
.font_family(t.mono_font_family.clone())
.child("hack doctor"),
)
.child(
Button::new("env-doctor")
.label("Copy command")
.small()
.on_click(cx.listener(|_, _, _, cx| {
cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(
"hack doctor".into(),
));
})),
),
);
}
if report.vars.is_empty() {
body = body.child(report.empty_label()).child(
Button::new("env-docs")
.label("Environment docs")
.small()
.on_click(cx.listener(|_, _, _, cx| {
cx.open_url(
"https://github.com/hack-dance/hack/blob/next/docs/env.md",
);
})),
);
} else {
body = body.child(
h_flex().gap_2().children(
["Name", "Layer / source", "Scope", "Kind", "Set", "Required"]
.map(|s| div().flex_1().child(s)),
),
);
for row in &report.vars {
body = body.child(
h_flex()
.gap_2()
.py_2()
.border_b_1()
.border_color(t.border)
.when(row.missing, |d| d.text_color(t.danger))
.children(
[
row.key.clone(),
row.layer
.clone()
.or_else(|| row.resolved_from.clone())
.unwrap_or_else(|| "—".into()),
row.scope.label(),
row.storage.kind.clone(),
if row.has_value { "✓" } else { "✗" }.into(),
if row.required { "Yes" } else { "No" }.into(),
]
.map(|s| div().flex_1().min_w_0().child(s)),
),
);
}
}
}
}
body
}

/// Shown, never acted on, once an action has run past
/// `LiveSource::LIFECYCLE_WARN_AFTER`.
fn slow_action_warning(&self, project: &str) -> Option<String> {
Expand Down
Loading
Loading