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