返回 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 crate::commands::CommandResult;
15 use crate::commands::traits::{CommandInfo, RegisterCommand};
16 use crate::dependencies::ExternalTool;
17 use crate::localization::MessageId;
18 use crate::tui::app::{App, AppAction};
19
20 /// Share the current session as a web URL.
21 fn share(app: &mut App, arg: Option<&str>) -> CommandResult {
22 let raw = arg.map(str::trim).unwrap_or("");
23
24 match raw {
25 "" => do_share(app),
26 "help" | "--help" | "-h" => CommandResult::message(
27 "/share — Export the current session as a shareable web URL.\n\
28 \n\
29 Usage:\n\
30 /share Export and upload the current session\n\
31 \n\
32 The session transcript is rendered as static HTML and uploaded\n\
33 to a GitHub Gist using the `gh` CLI. The Gist URL is displayed\n\
34 so you can paste it into Slack, GitHub, Twitter, etc."
35 .to_string(),
36 ),
37 _ => CommandResult::error(format!(
38 "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`."
39 )),
40 }
41 }
42
43 /// Export the session as HTML, upload to a Gist, and show the URL.
44 fn do_share(app: &mut App) -> CommandResult {
45 // Check if there's any session content to share
46 if app.history.is_empty() {
47 return CommandResult::error("Nothing to share. The current session is empty.");
48 }
49
50 // Sanity-check: the extra info block is optional; the session itself
51 // is what we share.
52 let history_len = app.history.len();
53 let model = &app.model;
54 let mode = app.mode.label();
55
56 // Use an AppAction to signal the engine to perform the async work.
57 CommandResult::with_message_and_action(
58 format!(
59 "Exporting {history_len} cell(s) from {model} ({mode}) session...\n\n\
60 The session will be rendered as static HTML and uploaded to a GitHub Gist.\n\
61 This requires the `gh` CLI to be installed and authenticated."
62 ),
63 AppAction::ShareSession {
64 history_len,
65 model: model.clone(),
66 mode: mode.to_string(),
67 },
68 )
69 }
70
71 /// Actually perform the share export.
72 ///
73 /// This is called from the engine after receiving the `ShareSession` action.
74 /// It renders the session as HTML and uploads it via `gh gist create`.
75 pub async fn perform_share(history_json: &str, model: &str, mode: &str) -> Result<String, String> {
76 // Build HTML from the session data
77 let html = render_session_html(history_json, model, mode);
78
79 // Write to a temp file
80 let tmp = match write_temp_html(&html) {
81 Ok(file) => file,
82 Err(e) => return Err(format!("Failed to write temp file: {e}")),
83 };
84
85 // Upload via `gh gist create`
86 let url = match upload_gist(tmp.path()).await {
87 Ok(url) => url,
88 Err(e) => return Err(format!("Failed to upload Gist: {e}")),
89 };
90
91 Ok(url)
92 }
93
94 /// Render the session as a standalone HTML page.
95 fn render_session_html(history_json: &str, model: &str, mode: &str) -> String {
96 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
97 let escaped_model = html_escape(model);
98 let escaped_mode = html_escape(mode);
99 let escaped_body = html_escape(history_json);
100
101 format!(
102 r#"<!DOCTYPE html>
103 <html lang="en">
104 <head>
105 <meta charset="UTF-8">
106 <meta name="viewport" content="width=device-width, initial-scale=1.0">
107 <title>codewhale Session Export</title>
108 <style>
109 body {{
110 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
111 max-width: 800px; margin: 2rem auto; padding: 0 1rem;
112 background: #0d1117; color: #c9d1d9;
113 }}
114 h1 {{ color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 0.5rem; }}
115 .meta {{ color: #8b949e; font-size: 0.9rem; margin-bottom: 2rem; }}
116 .message {{ margin: 1rem 0; padding: 0.75rem; border-radius: 6px; }}
117 .user {{ background: #1f2937; border-left: 3px solid #58a6ff; }}
118 .assistant {{ background: #161b22; border-left: 3px solid #3fb950; }}
119 .tool {{ background: #0d1117; border: 1px solid #30363d; font-family: monospace; font-size: 0.85rem; }}
120 pre {{ white-space: pre-wrap; word-wrap: break-word; margin: 0; }}
121 .footer {{ margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #30363d; color: #8b949e; font-size: 0.8rem; }}
122 </style>
123 </head>
124 <body>
125 <h1>codewhale Session</h1>
126 <div class="meta">
127 <strong>Model:</strong> {escaped_model} · <strong>Mode:</strong> {escaped_mode}<br>
128 <strong>Exported:</strong> {timestamp}
129 </div>
130 <pre>{escaped_body}</pre>
131 <div class="footer">
132 Generated by codewhale · https://github.com/Hmbown/CodeWhale
133 </div>
134 </body>
135 </html>"#,
136 )
137 }
138
139 /// HTML-escape special characters.
140 fn html_escape(s: &str) -> String {
141 s.replace('&', "&amp;")
142 .replace('<', "&lt;")
143 .replace('>', "&gt;")
144 .replace('"', "&quot;")
145 .replace('\'', "&#39;")
146 }
147
148 /// Write HTML to a secure temp file and keep it alive for upload.
149 fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> {
150 let mut tmp = tempfile::Builder::new()
151 .prefix("codewhale-share-")
152 .suffix(".html")
153 .tempfile()
154 .map_err(|e| format!("{e}"))?;
155 tmp.write_all(html.as_bytes()).map_err(|e| format!("{e}"))?;
156 Ok(tmp)
157 }
158
159 /// Upload a file as a GitHub Gist using the `gh` CLI.
160 async fn upload_gist(path: &Path) -> Result<String, String> {
161 let path_owned = path.to_path_buf();
162 let output = tokio::task::spawn_blocking(move || {
163 let mut cmd = crate::dependencies::Gh::command()
164 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "gh not found"))?;
165 cmd.args([
166 "gist",
167 "create",
168 "--public",
169 path_owned.to_string_lossy().as_ref(),
170 "--filename",
171 "session-export.html",
172 "--desc",
173 "codewhale Session Export",
174 ])
175 .output()
176 })
177 .await
178 .map_err(|join_err| format!("gh gist create panicked: {join_err}"))?
179 .map_err(|e| format!("Failed to run `gh gist create`: {e}"))?;
180
181 if !output.status.success() {
182 let stderr = String::from_utf8_lossy(&output.stderr);
183 return Err(format!("`gh gist create` failed: {stderr}"));
184 }
185
186 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
187 if stdout.is_empty() {
188 return Err("`gh gist create` returned no output".to_string());
189 }
190
191 Ok(stdout)
192 }
193
194 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
195 name: "share",
196 aliases: &[],
197 usage: "/share",
198 description_id: MessageId::CmdShareDescription,
199 };
200
201 pub(in crate::commands) struct ShareCmd;
202
203 impl RegisterCommand for ShareCmd {
204 fn info() -> &'static CommandInfo {
205 &COMMAND_INFO
206 }
207
208 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
209 share(app, arg)
210 }
211 }
212
213 #[cfg(test)]
214 mod tests {
215 use super::*;
216
217 #[test]
218 fn test_render_session_html_basic_structure() {
219 let html = render_session_html("[{}]", "deepseek-v4-pro", "agent");
220 assert!(html.contains("<!DOCTYPE html>"));
221 assert!(html.contains("deepseek-v4-pro"));
222 assert!(html.contains("agent"));
223 assert!(html.contains("[{}]"));
224 assert!(html.contains("codewhale"));
225 }
226
227 #[test]
228 fn test_html_escape_handles_special_chars() {
229 assert_eq!(html_escape("<script>"), "&lt;script&gt;");
230 assert_eq!(html_escape("a&b"), "a&amp;b");
231 assert_eq!(html_escape("\"quote\""), "&quot;quote&quot;");
232 }
233
234 #[test]
235 fn test_write_temp_html_creates_file() {
236 let file = write_temp_html("<html></html>").unwrap();
237 assert!(file.path().exists());
238 let content = std::fs::read_to_string(file.path()).unwrap();
239 assert_eq!(content, "<html></html>");
240 }
241
242 #[test]
243 fn test_render_session_html_metadata() {
244 let html = render_session_html("test data", "deepseek-v4-flash", "plan");
245 assert!(html.contains("deepseek-v4-flash"));
246 assert!(html.contains("plan"));
247 assert!(html.contains("test data"));
248 assert!(html.contains("Exported:"));
249 assert!(html.contains("https://github.com/Hmbown/CodeWhale"));
250 }
251 }
252
252 lines RUST