Side A delivers a coherent, self-contained UX/code improvement (grouped CLI panel, single border, hover-to-copy) plus a genuine correctness fix (JS-safe-quote assertion preventing broken onclick handlers), all with a clean diff. Side B is a large mechanical refactor (splitting forum.rs into files) that mostly moves code verbatim, reintroduces some duplicated inline JSON templating instead of reusing helper functions (arguably a slight regression), and bundles an unrelated sampling script, making its net lasting value less clear-cut despite its size.
constitution · epochs · watch · epoch 3
c_ca72f0995396 (tommy-mor) vs c_59974b9da42a (tommy-mor)
download prompt · raw event · cmp_52ee29a5b9b45f
council reasoning
A ships a real product improvement: multi-command CLI panels with row click-to-copy, shared border/layout, theme CSS, and hard asserts that commands stay single-quote JS-safe—changing behavior call sites (e.g. room page three panels → one). B is mostly a forum.rs file split (nav/ingest/access/etc.) plus thin RPC inlining and an unrelated macOS sample-fixture script, so it is maintainability churn rather than lasting feature or bugfix value.
Side A delivers a functional UI improvement by redesigning `cli_panel` to support multiple commands in one panel, making each command directly clickable-to-copy, and adding assertions that prevent unsafe characters from being embedded in single-quoted JavaScript literals. Side B is largely a structural refactor that splits forum code into new files and inlines some helper calls, with an added macOS profiling script, but the shown changes introduce little new project behavior compared with A's concrete usability and safety improvements.
sides
A — c_ca72f0995396 (tommy-mor)
message
[798c764d] feat(html): grouped cli_panel with hover-to-copy and JS-safe asserts Single bordered panel for multiple commands; rows copy on click without a separate copy control. Assert CLI strings contain no chars that would break single-quoted onclick JS. Made-with: Cursor
diff preview
diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs
index f6e45b05bb92126966e304619f80ef1d45be7a4b..075860914a6ce18bb0dfa73dc5651ef1a6318b67 100644
--- a/server/src/html/forum.rs
+++ b/server/src/html/forum.rs
@@ -718,7 +718,7 @@ pub async fn home(
}
div id="public-new-thread-ui-slot" {}
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
- (cli_panel("npx slugsocial public forum list"))
+ (cli_panel(&["npx slugsocial public forum list"]))
},
None,
theme_from_jar(&jar),
@@ -868,7 +868,7 @@ async fn thread_view_inner(
div id="thread-live-region" {
(compose_form(&nav, &tag, show_compose))
}
- (cli_panel(&cli))
+ (cli_panel(std::slice::from_ref(&cli)))
},
None,
theme_from_jar(&jar),
@@ -984,9 +984,7 @@ pub async fn room_page(
(new_thread_form_for_room(&nav, true, false))
}
}
- (cli_panel(&forum_cli))
- (cli_panel(&garden_cli))
- (cli_panel(&audit_cli))
+ (cli_panel(&[forum_cli, garden_cli, audit_cli]))
},
None,
theme_from_jar(&jar),
@@ -1409,7 +1407,7 @@ pub async fn user_profile_page(
}
}
}
- (cli_panel(&format!("npx slugsocial public forum list")))
+ (cli_panel(&[format!("npx slugsocial public forum list")]))
},
None,
theme_from_jar(&jar),
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 9b4789a79c3e293cbfc5f033a0eac8650320d94d..c8ce7de450f7d14e04020511e7eb7323bd487deb 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -139,7 +139,7 @@ pub async fn garden_index(
}
}
}
- (cli_panel("npx slugsocial garden tree"))
+ (cli_panel(&["npx slugsocial garden tree"]))
},
None,
theme_from_jar(&jar),
@@ -542,7 +542,7 @@ async fn render_scope_view(
ScopeId::Public => format!("npx slugsocial public garden body {}", path.as_str().trim_start_matches("https://slug.social/~/")),
ScopeId::Room(room_id) => format!("npx slugsocial private {room_id} garden body {}", path.as_str().trim_start_matches("https://slug.social/~/")),
};
- (cli_panel(&cli))
+ (cli_panel(std::slice::from_ref(&cli)))
},
None,
theme_from_jar(&jar),
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index e7f5bfb7a4b2dee2adf96447224c58d641092124..6617781a2e8e86c2e2693788ea7cd0eb0e3659a2 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -599,18 +599,38 @@ pub(super) fn render_linkified_with_embeds_in_scope(raw: &str, garden_prefix: &s
}
}
-/// Small CLI hint panel showing how to look up this page from the terminal.
-pub(super) fn cli_panel(cmd: &str) -> Markup {
+/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.
+fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {
+ assert!(
+ !s.contains('\\')
+ && !s.contains('\'')
+ && !s.contains('\n')
+ && !s.contains('\r'),
+ "cli_panel cmd must not contain `\\`, `'`, or newlines (got {s:?})"
+ );
+}
+
+/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.
+pub(super) fn cli_panel<I: AsRef<str>>(cmds: &[I]) -> Markup {
+ if cmds.is_empty() {
+ return html! {};
+ }
+ for cmd in cmds {
+ assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());
+ }
html! {
div class="cli-panel" {
span class="cli-panel-label muted" { "cli" }
- code class="cli-panel-cmd" { (cmd) }
- button
- class="cli-panel-copy"
- title="Copy to clipboard"
- onclick=(format!(r#"navigator.clipboard.writeText('{}'); this.textContent='✓'; setTimeout(() => this.textContent='copy', 2000);"#, cmd.replace("'", "\\'")))
- {
- "copy"
+ div class="cli-panel-cmds" {
+ @for cmd in cmds {
+ @let s = cmd.as_ref();
+ button type="button" class="cli-panel-row" title="Copy command" onclick=(format!(
+ r#"navigator.clipboard.writeText('{}');"#,
+ s
+ )) {
+ code class="cli-panel-cmd" { (s) }
+ }
+ }
}
}
}
diff --git a/server/src/html/search.rs b/server/src/html/search.rs
index 28ceaa53c3fcac6777311535e95fb771b19438f5..43e6ebf36cf0fe72c96f0f9d850bea51ac094c43 100644
--- a/server/src/html/search.rs
+++ b/server/src/html/search.rs
@@ -418,7 +418,7 @@ pub async fn search_page(
value=(query) autocomplete="off" autofocus;
}
(render_search_results(&results, &query))
- (cli_panel("npx slugsocial search <query>"))
+ (cli_panel(&["npx slugsocial search <query>"]))
},
None,
theme_from_jar(&jar),
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index e024a5c8b74139ae8be37b2a1bd17e4c3abe9324..764b66c8208e91e6138b83c534387cf43c85b8b5 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -610,7 +610,7 @@ code {
CLI PANEL — how to view this page from the terminal
---------------------------------------------------------------- */
div.cli-panel {
- align-items: baseline;
+ align-items: flex-start;
background: var(--g1);
border: var(--bv) solid;
border-color: var(--lo) var(--hi) var(--hi) var(--lo); /* inset */
@@ -621,11 +621,34 @@ div.cli-panel {
width: fit-content;
max-width: 100%;
}
+.cli-panel-cmds {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ min-width: 0;
+}
+button.cli-panel-row {
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: block;
+ font: inherit;
+ margin: 0;
+ padding: 2px 4px;
+ text-align: left;
+ width: 100%;
+}
+button.cli-panel-row:hover {
+ background: var(--g3);
+}
.cli-panel-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
flex-shrink: 0;
+ padding-top: 2px;
}
.cli-panel-cmd {
background: none;
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 00714575bfa533d1d9c66653b9089642e2b0a6ca..f89ecbc3a18eb9b2b27d1f7764330f6bd6987552 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -306,19 +306,41 @@ a.post-nav-btn:hover {
}
div.cli-panel {
- align-items: baseline;
+ align-items: flex-start;
border: 1px dashed var(--line);
display: flex;
- flex-wrap: wrap;
gap: 0.5rem;
margin: 0.65rem 0;
padding: 0.45rem 0.65rem;
}
+.cli-panel-cmds {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ flex: 1;
+ min-width: 0;
+}
+button.cli-panel-row {
+ background: transparent;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ display: block;
+ font: inherit;
+ margin: 0;
+ padding: 0.1rem 0.2rem;
+ text-align: left;
+ width: 100%;
+}
+button.cli-panel-row:hover {
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+}
.cli-panel-label {
color: var(--ink-dim);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
+ padding-top: 0.12rem;
}
.cli-panel-cmd {
color: var(--accent);
B — c_59974b9da42a (tommy-mor)
message
[c99adc26] refactor forum into files
diff preview
diff --git a/bb.edn b/bb.edn
index 4680e82d4227057d2eea6041dc5647beb759131d..ce0f6fcd45a67ead3295b90db2a301e9b304f3da 100644
--- a/bb.edn
+++ b/bb.edn
@@ -51,6 +51,11 @@
:requires ([test.walkthrough-fixture :as walkthrough-fixture])
:task (walkthrough-fixture/run-fixture)}
+ sample-fixture
+ {:doc "macOS: run `sample` on process(es) listening on the fixture TCP port (default 8080). Usage: bb sample-fixture [PORT] [DURATION_SEC] [OUT_DIR]"
+ :requires ([scripts.sample-fixture :as sample-fixture])
+ :task (apply sample-fixture/-main *command-line-args*)}
+
perf
{:doc "Performance test: concurrent HTTP requests to detect blocking I/O"
:requires ([scripts.perf :as perf])
diff --git a/scripts/sample_fixture.bb b/scripts/sample_fixture.bb
new file mode 100644
index 0000000000000000000000000000000000000000..ac2368623c5043ba6e027afef6b5b37d392767b0
--- /dev/null
+++ b/scripts/sample_fixture.bb
@@ -0,0 +1,70 @@
+(ns scripts.sample-fixture
+ "Find process(es) listening on the fixture port (default 8080) and run macOS `sample`."
+ (:require [babashka.fs :as fs]
+ [babashka.process :as p]
+ [clojure.string :as str]))
+
+(defn- usage []
+ (println "Usage: bb sample-fixture [PORT] [DURATION_SEC] [OUT_DIR]")
+ (println "")
+ (println " Finds PIDs bound to TCP LISTEN on PORT (default 8080), then runs")
+ (println " `sample` for each PID. OUT_DIR defaults to the current directory.")
+ (println "")
+ (println " Example: bb sample-fixture")
+ (println " bb sample-fixture 8080 10")
+ (println " bb sample-fixture 8080 5 /tmp")
+ (println "")
+ (println " Requires macOS (the `sample` tool)."))
+
+(defn- parse-long* [s]
+ (try (Long/parseLong s)
+ (catch NumberFormatException _ nil)))
+
+(defn- listen-pids [port]
+ (let [spec (str "TCP:" port)
+ {:keys [out exit]}
+ @(p/process ["lsof" "-nP" (str "-i" spec) "-sTCP:LISTEN" "-t"]
+ {:out :string :err :string})]
+ (when (zero? exit)
+ (->> (str/split-lines out)
+ (map str/trim)
+ (remove str/blank?)
+ (distinct)
+ vec))))
+
+(defn- sample-bin []
+ (or (fs/which "sample")
+ (throw (ex-info "macOS `sample` not found on PATH" {}))))
+
+(defn- run-sample! [sample duration-sec pid out-file]
+ (println (str "sampling PID " pid " for " duration-sec "s → " out-file))
+ (let [{:keys [exit err]} @(p/process [sample (str pid) (str duration-sec) "-file" out-file]
+ {:out :inherit :err :inherit})]
+ (when-not (zero? exit)
+ (binding [*out* *err*]
+ (println "sample failed:" err))
+ (System/exit exit))))
+
+(defn -main [& args]
+ (when (some #{"-h" "--help" "help"} args)
+ (usage)
+ (System/exit 0))
+ (let [port (or (some-> (first args) parse-long*) 8080)
+ duration-sec (or (some-> (second args) parse-long*) 5)
+ out-dir (or (nth args 2 nil) ".")
+ pids (listen-pids port)]
+ (when (or (nil? pids) (empty? pids))
+ (binding [*out* *err*]
+ (println (str "No process listening on TCP " port " (LISTEN). Is `bb fixture` running?")))
+ (System/exit 1))
+ (when-not (fs/exists? out-dir)
+ (binding [*out* *err*]
+ (println "Output directory does not exist:" out-dir))
+ (System/exit 1))
+ (let [sample (sample-bin)
+ ts (str (System/currentTimeMillis))]
+ (println (str "port " port " → PIDs " (str/join ", " pids)))
+ (doseq [pid pids]
+ (let [out-file (str (fs/path out-dir) "/slug-sample-" port "-" pid "-" ts ".txt")]
+ (run-sample! sample duration-sec pid (str out-file))))
+ (println "done."))))
diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs
index a55fc1f79d03719eb74b216865b2c15199c48ea7..5ad8dfc84dd735d589432e2c613ff687a75f2e61 100644
--- a/server/src/html/forum.rs
+++ b/server/src/html/forum.rs
@@ -380,22 +380,6 @@ fn room_members_inner(members: &[RoomMemberRow]) -> Markup {
}
}
-pub(crate) fn set_room_members_expanded_rpc(room_wire: &str, expanded: bool) -> String {
- template_json_compact(&HtmlUiAction::SetRoomMembersExpanded {
- room_wire: room_wire.to_string(),
- expanded,
- })
- .expect("static json")
-}
-
-pub(crate) fn set_room_new_thread_compose_expanded_rpc(nav: &ThreadNav, expanded: bool) -> String {
- template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded {
- room_wire: nav.room_wire.clone(),
- expanded,
- })
- .expect("static json")
-}
-
/// Fragment for `#room-members-section` — expand/collapse is server-driven via `POST /ui`.
pub(crate) fn room_members_section_markup(
reduced: &ReducerState,
@@ -406,13 +390,14 @@ pub(crate) fn room_members_section_markup(
if members.is_empty() {
return html! {};
}
- let rpc_open = set_room_members_expanded_rpc(room_id, true);
- let rpc_close = set_room_members_expanded_rpc(room_id, false);
html! {
div id="room-members-section" {
@if members_expanded {
form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(rpc_close);
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded {
+ room_wire: room_id.to_string(),
+ expanded: false,
+ }).expect("static json"));
button type="submit" class="form-toggle" aria-expanded="true" {
"hide members & permissions"
}
@@ -422,7 +407,10 @@ pub(crate) fn room_members_section_markup(
}
} @else {
form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(rpc_open);
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomMembersExpanded {
+ room_wire: room_id.to_string(),
+ expanded: true,
+ }).expect("static json"));
button type="submit" class="form-toggle" aria-expanded="false" {
"members & permissions"
}
@@ -514,28 +502,24 @@ fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup {
if !show {
return html! {};
}
- let rpc_post = template_json_compact(&json!({
- "action": "post_ingest",
- "room": nav.room_wire,
- "thread_tag": thread_tag,
- "text": {"$form": "text"},
- "error_target": "thread-compose-errors",
- "form_id": "thread-compose-form",
- }))
- .unwrap();
- let rpc_check = template_json_compact(&json!({
- "action": "check_ingest",
- "room": nav.room_wire,
- "thread_tag": thread_tag,
- "text": {"$form": "text"},
- "error_target": "thread-compose-errors",
- "form_id": "thread-compose-form",
- }))
- .unwrap();
html! {
section class="compose" id="thread-compose" {
- form id="thread-compose-form" method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(rpc_check) {
- input type="hidden" name=(UI_RPC_FIELD) value=(rpc_post);
+ form id="thread-compose-form" method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(template_json_compact(&json!({
+ "action": "check_ingest",
+ "room": nav.room_wire,
+ "thread_tag": thread_tag,
+ "text": {"$form": "text"},
+ "error_target": "thread-compose-errors",
+ "form_id": "thread-compose-form",
+ })).unwrap()) {
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({
+ "action": "post_ingest",
+ "room": nav.room_wire,
+ "thread_tag": thread_tag,
+ "text": {"$form": "text"},
+ "error_target": "thread-compose-errors",
+ "form_id": "thread-compose-form",
+ })).unwrap());
textarea name="text" rows="5" cols="80" placeholder="prose or ~/items and votes…" {}
p {
button type="submit" { "post" }
@@ -712,7 +696,7 @@ pub async fn home(
p class="muted" { "dark = time-ordered · light = vote-ranked" }
div class="thread-feed-toolbar" {
form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(expand_public_new_thread_rpc_value());
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect("static json"));
button type="submit" class="section-add-btn" { "+" }
}
}
@@ -1002,14 +986,15 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool)
if !show {
return html! {};
}
- let rpc_open = set_room_new_thread_compose_expanded_rpc(nav, true);
- let rpc_close = set_room_new_thread_compose_expanded_rpc(nav, false);
// Single root for Idiomorph when morphing `#room-new-thread-ui-slot` (expanded has form + section).
html! {
div class="room-new-thread-slot-inner" {
@if compose_expanded {
form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(rpc_close);
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded {
+ room_wire: nav.room_wire.clone(),
+ expanded: false,
+ }).expect("static json"));
button type="submit" class="form-toggle" aria-expanded="true" {
"-"
}
@@ -1039,7 +1024,10 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool)
}
} @else {
form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(rpc_open);
+ input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded {
+ room_wire: nav.room_wire.clone(),
+ expanded: true,
+ }).expect("static json"));
button type="submit" class="form-toggle" aria-expanded="false" {
"+"
}
@@ -1049,10 +1037,6 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool)
}
}
-pub(crate) fn expand_public_new_thread_rpc_value() -> String {
- template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect("static json")
-}
-
pub(crate) fn login_to_post_hint_markup() -> Markup {
html! {
p class="muted" { "log in to post" }
diff --git a/server/src/html/forum/access.rs b/server/src/html/forum/access.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f9f7139b63442a6ef3a927b77a72805f15feec3f
--- /dev/null
+++ b/server/src/html/forum/access.rs
@@ -0,0 +1,16 @@
+use crate::events::ThreadCapability;
+use crate::reducer::ReducerState;
+
+pub(crate) fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&str>) -> bool {
+ if !reduced.rooms.contains(room_id) {
+ return false;
+ }
+ let Some(u) = username else {
+ return false;
+ };
+ reduced.user_has_cap(room_id, u, ThreadCapability::View)
+}
+
+pub(crate) fn user_can_post_room(reduced: &ReducerState, room_id: &str, username: &str) -> bool {
+ reduced.user_has_cap(room_id, username, ThreadCapability::Post)
+}
diff --git a/server/src/html/forum/ingest
… preview truncated; 19,729 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.