返回 CodeWhale
share.rs
根目录 / crates / tui / src / commands / groups / project / share.rs
1 //! /share command — export the current session as a shareable web URL.
2 //!
3 //! Renders the current session transcript as a static HTML page, uploads it
4 //! to a GitHub Gist via the `gh` CLI, and displays the resulting URL.
5 //!
6 //! # Usage
7 //!
8 //! - `/share` — export the current session and print the Gist URL
9 //! - `/share help` — show usage
10
11 use std::io::Write;
12 use std::path::Path;
13
14 use codewhale_command_contract::facets::CommandProjectContext;
15 use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
16 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
17
18 use crate::commands::CommandResult;
19 use crate::dependencies::ExternalTool;
20 use crate::tui::app::AppAction;
21
22 /// Share the current session as a web URL.
23 fn share(project: &dyn CommandProjectContext, arg: Option<&str>) -> CommandResult {
24 let raw = arg.map(str::trim).unwrap_or("");
25
26 match raw {
27 "" => do_share(project),
28 "help" | "--help" | "-h" => CommandResult::message(
29 "/share — Export the current session as a shareable web URL.\n\
30 \n\
31 Usage:\n\
32 /share Export and upload the current session\n\
33 \n\
34 The session transcript is rendered as static HTML and uploaded\n\
35 to a GitHub Gist using the `gh` CLI. The Gist URL is displayed\n\
36 so you can paste it into Slack, GitHub, Twitter, etc."
37 .to_string(),
38 ),
39 _ => CommandResult::error(format!(
40 "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`."
41 )),
42 }
43 }
44
45 /// Export the session as HTML, upload to a Gist, and show the URL.
46 fn do_share(project: &dyn CommandProjectContext) -> CommandResult {
47 let share = project.share_projection();
48
49 // Check if there's any session content to share
50 if share.history_is_empty {
51 return CommandResult::error("Nothing to share. The current session is empty.");
52 }
53
54 // Use an AppAction to signal the engine to perform the async work.
55 CommandResult::with_message_and_action(
56 format!(
57 "Exporting {} cell(s) from {} ({}) session...\n\n\
58 The session will be rendered as static HTML and uploaded to a GitHub Gist.\n\
59 This requires the `gh` CLI to be installed and authenticated.",
60 share.history_len, share.model, share.mode_label
61 ),
62 AppAction::ShareSession {
63 history_len: share.history_len,
64 model: share.model,
65 mode: share.mode_label,
66 },
67 )
68 }
69
70 /// Actually perform the share export.
71 ///
72 /// This is called from the engine after receiving the `ShareSession` action.
73 /// It renders the session as HTML and uploads it via `gh gist create`.
74 pub async fn perform_share(history_json: &str, model: &str, mode: &str) -> Result<String, String> {
75 // Build HTML from the session data
76 let html = render_session_html(history_json, model, mode);
77
78 // Write to a temp file
79 let tmp = match write_temp_html(&html) {
80 Ok(file) => file,
81 Err(e) => return Err(format!("Failed to write temp file: {e}")),
82 };
83
84 // Upload via `gh gist create`
85 let url = match upload_gist(tmp.path()).await {
86 Ok(url) => url,
87 Err(e) => return Err(format!("Failed to upload Gist: {e}")),
88 };
89
90 Ok(url)
91 }
92
93 /// Render the session as a standalone HTML page.
94 fn render_session_html(history_json: &str, model: &str, mode: &str) -> String {
95 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
96 let escaped_model = html_escape(model);
97 let escaped_mode = html_escape(mode);
98 let escaped_body = html_escape(history_json);
99
100 format!(
101 r#"<!DOCTYPE html>
102 <html lang="en">
103 <head>
104 <meta charset="UTF-8">
105 <meta name="viewport" content="width=device-width, initial-scale=1.0">
106 <title>codewhale Session Export</title>
107 <style>
108 body {{
109 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
110 max-width: 800px; margin: 2rem auto; padding: 0 1rem;
111 background: #0d1117; color: #c9d1d9;
112 }}
113 h1 {{ color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 0.5rem; }}
114 .meta {{ color: #8b949e; font-size: 0.9rem; margin-bottom: 2rem; }}
115 .message {{ margin: 1rem 0; padding: 0.75rem; border-radius: 6px; }}
116 .user {{ background: #1f2937; border-left: 3px solid #58a6ff; }}
117 .assistant {{ background: #161b22; border-left: 3px solid #3fb950; }}
118 .tool {{ background: #0d1117; border: 1px solid #30363d; font-family: monospace; font-size: 0.85rem; }}
119 pre {{ white-space: pre-wrap; word-wrap: break-word; margin: 0; }}
120 .footer {{ margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #30363d; color: #8b949e; font-size: 0.8rem; }}
121 </style>
122 </head>
123 <body>
124 <h1>codewhale Session</h1>
125 <div class="meta">
126 <strong>Model:</strong> {escaped_model} · <strong>Mode:</strong> {escaped_mode}<br>
127 <strong>Exported:</strong> {timestamp}
128 </div>
129 <pre>{escaped_body}</pre>
130 <div class="footer">
131 Generated by codewhale · https://github.com/Hmbown/CodeWhale
132 </div>
133 </body>
134 </html>"#,
135 )
136 }
137
138 /// HTML-escape special characters.
139 fn html_escape(s: &str) -> String {
140 s.replace('&', "&amp;")
141 .replace('<', "&lt;")
142 .replace('>', "&gt;")
143 .replace('"', "&quot;")
144 .replace('\'', "&#39;")
145 }
146
147 /// Write HTML to a secure temp file and keep it alive for upload.
148 fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> {
149 let mut tmp = tempfile::Builder::new()
150 .prefix("codewhale-share-")
151 .suffix(".html")
152 .tempfile()
153 .map_err(|e| format!("{e}"))?;
154 tmp.write_all(html.as_bytes()).map_err(|e| format!("{e}"))?;
155 Ok(tmp)
156 }
157
158 /// Upload a file as a GitHub Gist using the `gh` CLI.
159 async fn upload_gist(path: &Path) -> Result<String, String> {
160 let path_owned = path.to_path_buf();
161 let output = tokio::task::spawn_blocking(move || {
162 let mut cmd = crate::dependencies::Gh::command()
163 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "gh not found"))?;
164 cmd.args([
165 "gist",
166 "create",
167 "--public",
168 path_owned.to_string_lossy().as_ref(),
169 "--filename",
170 "session-export.html",
171 "--desc",
172 "codewhale Session Export",
173 ])
174 .output()
175 })
176 .await
177 .map_err(|join_err| format!("gh gist create panicked: {join_err}"))?
178 .map_err(|e| format!("Failed to run `gh gist create`: {e}"))?;
179
180 if !output.status.success() {
181 let stderr = String::from_utf8_lossy(&output.stderr);
182 return Err(format!("`gh gist create` failed: {stderr}"));
183 }
184
185 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
186 if stdout.is_empty() {
187 return Err("`gh gist create` returned no output".to_string());
188 }
189
190 Ok(stdout)
191 }
192
193 pub(in crate::commands) const SHARE_INFO: CommandInfo = CommandInfo {
194 name: "share",
195 aliases: &[],
196 usage: "/share",
197 description_key: "cmd_share_description",
198 };
199
200 pub(in crate::commands) struct ShareCmd;
201
202 impl RegisterCommand<CommandResult> for ShareCmd {
203 fn info() -> &'static CommandInfo {
204 &SHARE_INFO
205 }
206
207 fn handler() -> CommandHandler<CommandResult> {
208 CommandHandler::Contextual {
209 capabilities: codewhale_command_contract::handler::CommandCapabilities::PROJECT,
210 handler: share_contextual,
211 }
212 }
213 }
214
215 /// Contextual `/share` dispatch (FEAT-021 Phase 4).
216 ///
217 /// Destructures the declared `PROJECT` facet with a safe missing-facet error.
218 fn share_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
219 let mut parts = contexts.into_parts();
220 let Some(project) = parts.project.as_deref_mut() else {
221 return CommandResult::error("Command capability unavailable: project");
222 };
223 share(project, arg)
224 }
225
226 #[cfg(test)]
227 mod tests {
228 use super::*;
229 use codewhale_command_contract::facets::{
230 ProjectGoalState, ProjectGoalStatus, ProjectShareProjection,
231 };
232
233 /// Deterministic fake project facet over portable values only.
234 struct FakeProject {
235 share: ProjectShareProjection,
236 }
237
238 impl CommandProjectContext for FakeProject {
239 fn lsp_enabled(&self) -> bool {
240 false
241 }
242
243 fn lsp_set(&mut self, _enabled: bool) -> Result<(), String> {
244 Ok(())
245 }
246
247 fn share_projection(&self) -> ProjectShareProjection {
248 self.share.clone()
249 }
250
251 fn goal_state(&self) -> ProjectGoalState {
252 ProjectGoalState {
253 objective: None,
254 status: ProjectGoalStatus::Active,
255 pause_reason: None,
256 started_at_elapsed_seconds: None,
257 time_used_seconds: 0,
258 token_budget: None,
259 tokens_used: 0,
260 session_total_tokens: 0,
261 continuation_count: 0,
262 pending_controls: false,
263 last_known_objective: None,
264 last_known_status: None,
265 conversation_present: false,
266 is_loading: false,
267 goal_continuation_waiting: false,
268 }
269 }
270 }
271
272 fn project_with_history() -> FakeProject {
273 FakeProject {
274 share: ProjectShareProjection {
275 history_is_empty: false,
276 history_len: 3,
277 model: "deepseek-v4-pro".to_string(),
278 mode_label: "ACT".to_string(),
279 },
280 }
281 }
282
283 fn project_empty() -> FakeProject {
284 FakeProject {
285 share: ProjectShareProjection {
286 history_is_empty: true,
287 history_len: 0,
288 model: String::new(),
289 mode_label: String::new(),
290 },
291 }
292 }
293
294 #[test]
295 fn share_empty_session_errors() {
296 let project = project_empty();
297 let result = share(&project, Some(""));
298 assert!(result.is_error);
299 assert!(
300 result.message.unwrap().contains("Nothing to share"),
301 "empty share must error"
302 );
303 }
304
305 #[test]
306 fn share_populated_session_emits_exact_action_and_message() {
307 let project = project_with_history();
308 let result = share(&project, Some(""));
309 assert!(!result.is_error);
310 let msg = result.message.unwrap();
311 assert!(
312 msg.contains("Exporting 3 cell(s) from deepseek-v4-pro (ACT) session..."),
313 "message was: {msg}"
314 );
315 assert!(
316 matches!(
317 result.action,
318 Some(AppAction::ShareSession {
319 history_len: 3,
320 ref model,
321 ref mode,
322 }) if model == "deepseek-v4-pro" && mode == "ACT"
323 ),
324 "action was: {:?}",
325 result.action
326 );
327 }
328
329 #[test]
330 fn share_help_and_unknown_routes() {
331 let project = project_with_history();
332 for arg in ["help", "--help", "-h"] {
333 let result = share(&project, Some(arg));
334 assert!(!result.is_error);
335 assert!(result.message.unwrap().contains("/share"));
336 }
337 let result = share(&project, Some("bogus"));
338 assert!(result.is_error);
339 assert!(
340 result
341 .message
342 .unwrap()
343 .contains("Unknown /share argument `bogus`")
344 );
345 }
346
347 #[test]
348 fn missing_project_facet_fails_safely() {
349 let result = share_contextual(CommandContexts::empty(), Some(""));
350 assert!(result.is_error);
351 assert!(
352 result
353 .message
354 .unwrap()
355 .contains("Command capability unavailable: project")
356 );
357 }
358
359 #[test]
360 fn test_render_session_html_basic_structure() {
361 let html = render_session_html("[{}]", "deepseek-v4-pro", "agent");
362 assert!(html.contains("<!DOCTYPE html>"));
363 assert!(html.contains("deepseek-v4-pro"));
364 assert!(html.contains("agent"));
365 assert!(html.contains("[{}]"));
366 assert!(html.contains("codewhale"));
367 }
368
369 #[test]
370 fn test_html_escape_handles_special_chars() {
371 assert_eq!(html_escape("<script>"), "&lt;script&gt;");
372 assert_eq!(html_escape("a&b"), "a&amp;b");
373 assert_eq!(html_escape("\"quote\""), "&quot;quote&quot;");
374 }
375
376 #[test]
377 fn test_write_temp_html_creates_file() {
378 let file = write_temp_html("<html></html>").unwrap();
379 assert!(file.path().exists());
380 let content = std::fs::read_to_string(file.path()).unwrap();
381 assert_eq!(content, "<html></html>");
382 }
383
384 #[test]
385 fn test_render_session_html_metadata() {
386 let html = render_session_html("test data", "deepseek-v4-flash", "plan");
387 assert!(html.contains("deepseek-v4-flash"));
388 assert!(html.contains("plan"));
389 assert!(html.contains("test data"));
390 assert!(html.contains("Exported:"));
391 assert!(html.contains("https://github.com/Hmbown/CodeWhale"));
392 }
393 }
394
394 lines RUST