From 7a829d0570c2c003a8cff6f1b19ba721ec73cbd9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 24 Mar 2026 17:17:48 -0500 Subject: [PATCH] feat: github gist import --- Cargo.lock | 221 ++++++++++++++- README.md | 7 +- crates/core/Cargo.toml | 1 + crates/core/src/github/gists.rs | 141 ++++++++++ crates/core/src/github/mod.rs | 3 + crates/core/src/lib.rs | 1 + crates/store/src/lib.rs | 83 +++--- docs/release/README.md | 1 + docs/release/v0.3.0.md | 26 ++ docs/specs/integration/gh.md | 10 +- docs/tasks/gh.md | 19 +- src-tauri/src/commands.rs | 12 +- src-tauri/src/commands/github.rs | 31 +++ src-tauri/src/lib.rs | 2 + src/__tests__/Sidebar.test.tsx | 19 +- src/__tests__/WorkspacePanel.test.tsx | 4 + src/__tests__/ports.test.ts | 19 ++ src/__tests__/useAtProtoController.test.tsx | 40 ++- src/components/AppLayout/WorkspacePanel.tsx | 3 + src/components/AtProto/ImportSheet.tsx | 251 +++++++++++++----- src/components/Sidebar/Sidebar.tsx | 115 ++++++-- .../StandardSite/PostImportSheet.tsx | 48 ++-- .../Toolbar/SaveStatusIndicator.tsx | 6 +- src/components/Toolbar/Toolbar.tsx | 12 +- src/components/icons.tsx | 14 +- src/hooks/controllers/useAtProtoController.ts | 172 +++++++++++- .../controllers/useWorkspaceViewController.ts | 2 + src/hooks/useEditorImageHandlers.ts | 6 +- src/ports/commands.ts | 11 + src/ports/types.ts | 7 + src/types.ts | 14 + 31 files changed, 1103 insertions(+), 198 deletions(-) create mode 100644 crates/core/src/github/gists.rs create mode 100644 crates/core/src/github/mod.rs create mode 100644 docs/release/README.md create mode 100644 docs/release/v0.3.0.md create mode 100644 src-tauri/src/commands/github.rs diff --git a/Cargo.lock b/Cargo.lock index ff41b02..dfba2ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -706,6 +715,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cargo-platform" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "cargo_metadata" version = "0.19.2" @@ -713,7 +732,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", - "cargo-platform", + "cargo-platform 0.1.9", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform 0.3.2", "semver", "serde", "serde_json", @@ -1550,6 +1583,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1993,6 +2032,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-buffered" version = "0.2.13" @@ -2013,6 +2067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -2080,6 +2135,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2811,7 +2867,9 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -2819,6 +2877,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -3535,6 +3606,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + [[package]] name = "k256" version = "0.13.4" @@ -4139,6 +4233,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -4444,6 +4548,48 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "octocrab" +version = "0.49.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481d01ffe3fa4347e55474798e16d8d678aab19b8d7ca631ebb3c607cc87f9db" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.22.1", + "bytes", + "cargo_metadata 0.23.1", + "cfg-if", + "chrono", + "either", + "futures", + "futures-util", + "getrandom 0.2.17", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tokio", + "tower", + "tower-http", + "tracing", + "url", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -4667,6 +4813,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5782,6 +5938,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -5790,6 +5947,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -5948,6 +6117,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6313,6 +6491,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -6365,6 +6555,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "socket2" version = "0.5.10" @@ -7039,7 +7250,7 @@ checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" dependencies = [ "anyhow", "brotli 8.0.2", - "cargo_metadata", + "cargo_metadata 0.19.2", "ctor", "dunce", "glob", @@ -7449,8 +7660,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -7474,6 +7687,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -7494,6 +7708,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -8091,6 +8306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -8904,6 +9120,7 @@ dependencies = [ "comrak", "jacquard", "log", + "octocrab", "reqwest 0.12.28", "serde", "serde_json", diff --git a/README.md b/README.md index c126a1f..19ece61 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,8 @@ Commonplace is a distraction-free markdown editor. - Multi-document tabs with Rust-backed session restore - Rule-based writing assistance with style check diagnostics and parts-of-speech highlighting - Quick Capture window (`#/quick-capture`) for global shortcut capture flows -- Export dialog with PDF preview, DOCX export, plaintext export, and source-markdown save -- Import Tangled strings as documents -- Publish documents as Tangled strings +- Export as PDF, DOCX or plaintext/markdown with a preview +- Import Tangled strings & Github gists into your workspace ### File Management @@ -73,6 +72,8 @@ Export polished PDFs with inline preview, Word-compatible DOCX files, clean plai - Backend: Tauri 2, Rust workspace (`core`, `markdown`, `store`) - Testing: Vitest + Testing Library (frontend), `cargo test` (Rust) +- Integrations: AT Protocol integration via `jacquard`. Github via `octocrab`. +
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2762dca..a090ec5 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -13,6 +13,7 @@ jacquard = "0.10" unicode-segmentation = "1.12.0" comrak = "0.50" reqwest = "0.12" +octocrab = "0.49.6" log = "0.4" [dev-dependencies] diff --git a/crates/core/src/github/gists.rs b/crates/core/src/github/gists.rs new file mode 100644 index 0000000..8dff44b --- /dev/null +++ b/crates/core/src/github/gists.rs @@ -0,0 +1,141 @@ +use crate::{AppError, ErrorCode}; +use octocrab::Octocrab; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GistRecord { + pub id: String, + pub filename: String, + pub description: String, + pub contents: String, + pub language: Option, + pub public: bool, + pub html_url: String, + pub owner: String, + pub created_at: String, + pub updated_at: String, + pub file_count: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct GithubState; + +impl GithubState { + pub fn new() -> Self { + Self + } + + pub async fn gist_list_public(&self, username: &str) -> Result, AppError> { + let trimmed_username = username.trim(); + if trimmed_username.is_empty() { + return Err(AppError::new(ErrorCode::InvalidPath, "GitHub username is required")); + } + + let client = build_client()?; + + let route = format!("/users/{trimmed_username}/gists"); + let params = [("per_page", "100")]; + + let gists = client + .get::, _, _>(route, Some(¶ms)) + .await + .map_err(|error| map_github_error("Failed to list public gists", error))?; + + gists.into_iter().map(|gist| gist.into_record()).collect() + } + + pub async fn gist_get(&self, gist_id: &str) -> Result { + let trimmed_gist_id = gist_id.trim(); + if trimmed_gist_id.is_empty() { + return Err(AppError::new(ErrorCode::InvalidPath, "Gist ID is required")); + } + + let client = build_client()?; + + let route = format!("/gists/{trimmed_gist_id}"); + let gist = client + .get::(route, None::<&[(&str, &str)]>) + .await + .map_err(|error| map_github_error("Failed to fetch gist", error))?; + + gist.into_record() + } +} + +fn build_client() -> Result { + Octocrab::builder() + .build() + .map_err(|error| AppError::io(format!("Failed to initialize GitHub client: {}", error))) +} + +#[derive(Debug, Deserialize)] +struct ApiGist { + id: String, + description: Option, + public: bool, + html_url: String, + files: HashMap, + owner: Option, + created_at: String, + updated_at: String, +} + +#[derive(Debug, Deserialize)] +struct ApiGistFile { + filename: Option, + language: Option, + content: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiOwner { + login: String, +} + +impl ApiGist { + fn into_record(self) -> Result { + let file_count = self.files.len(); + let primary_file = select_primary_file(&self.files) + .ok_or_else(|| AppError::new(ErrorCode::Parse, "Gist does not contain any files"))?; + + Ok(GistRecord { + id: self.id, + filename: primary_file.filename.clone().unwrap_or_else(|| "untitled".to_string()), + description: self.description.unwrap_or_default(), + contents: primary_file.content.clone().unwrap_or_default(), + language: primary_file.language.clone(), + public: self.public, + html_url: self.html_url, + owner: self + .owner + .map(|owner| owner.login) + .unwrap_or_else(|| "unknown".to_string()), + created_at: self.created_at, + updated_at: self.updated_at, + file_count, + }) + } +} + +fn select_primary_file(files: &HashMap) -> Option<&ApiGistFile> { + let mut entries: Vec<(&String, &ApiGistFile)> = files.iter().collect(); + entries.sort_by(|left, right| left.0.cmp(right.0)); + entries.first().map(|(_, file)| *file) +} + +fn map_github_error(context: &str, error: octocrab::Error) -> AppError { + let message = error.to_string(); + let lower = message.to_ascii_lowercase(); + + if message.contains("404") { + return AppError::not_found(format!("{}: not found", context)); + } + + if lower.contains("rate limit") || message.contains("403") { + return AppError::permission_denied(format!("{}: GitHub rate limit exceeded", context)); + } + + AppError::io(format!("{}: {}", context, message)) +} diff --git a/crates/core/src/github/mod.rs b/crates/core/src/github/mod.rs new file mode 100644 index 0000000..dea0c12 --- /dev/null +++ b/crates/core/src/github/mod.rs @@ -0,0 +1,3 @@ +mod gists; + +pub use gists::{GistRecord, GithubState}; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3436697..f2199c5 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; pub mod atproto; +pub mod github; mod nlp; pub use nlp::{ PatternCategory, PatternMatcher, StyleCategorySettings, StyleMatch, StylePattern, StylePatternInput, diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index ef53468..10bf922 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -43,6 +43,17 @@ pub struct StyleCheckPattern { pub replacement: Option, } +/// Helper tuple for managing file content and associated metadata during document operations +struct FileContents( + String, + Encoding, + LineEnding, + Option, + Option, + Option, + Option, +); + /// Manages the SQLite database for the application /// /// TODO: Break this impl up into smaller "Repositories" @@ -1152,45 +1163,39 @@ impl Store { let is_image = file_utils::is_supported_image_path(&doc_id.rel_path); - let (text, encoding, line_ending, title, word_count, content_hash, content_type): ( - String, - Encoding, - LineEnding, - Option, - Option, - Option, - Option, - ) = if is_image { - let title = file_utils::fallback_title_from_path(&doc_id.rel_path).unwrap_or_else(|| { - doc_id - .rel_path - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("image") - .to_string() - }); - ( - String::new(), - Encoding::default(), - LineEnding::default(), - Some(title), - None, - None, - file_utils::image_mime_type_for_path(&doc_id.rel_path).map(|value| value.to_string()), - ) - } else { - let mut file = File::open(&full_path).map_err(|e| AppError::io(format!("Failed to open file: {}", e)))?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|e| AppError::io(format!("Failed to read file: {}", e)))?; - - let (text, encoding) = text_utils::detect_and_decode(&bytes)?; - let line_ending = LineEnding::detect(&text); - let (title, word_count) = Self::derive_text_metadata(&text, &doc_id.rel_path); - let content_hash = Some(text_utils::hash_text(&text)); - - (text, encoding, line_ending, title, Some(word_count), content_hash, None) - }; + let FileContents(text, encoding, line_ending, title, word_count, content_hash, content_type): FileContents = + if is_image { + let title = file_utils::fallback_title_from_path(&doc_id.rel_path).unwrap_or_else(|| { + doc_id + .rel_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("image") + .to_string() + }); + FileContents( + String::new(), + Encoding::default(), + LineEnding::default(), + Some(title), + None, + None, + file_utils::image_mime_type_for_path(&doc_id.rel_path).map(|value| value.to_string()), + ) + } else { + let mut file = + File::open(&full_path).map_err(|e| AppError::io(format!("Failed to open file: {}", e)))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|e| AppError::io(format!("Failed to read file: {}", e)))?; + + let (text, encoding) = text_utils::detect_and_decode(&bytes)?; + let line_ending = LineEnding::detect(&text); + let (title, word_count) = Self::derive_text_metadata(&text, &doc_id.rel_path); + let content_hash = Some(text_utils::hash_text(&text)); + + FileContents(text, encoding, line_ending, title, Some(word_count), content_hash, None) + }; let metadata = std::fs::metadata(&full_path).map_err(|e| AppError::io(format!("Failed to read metadata: {}", e)))?; diff --git a/docs/release/README.md b/docs/release/README.md new file mode 100644 index 0000000..57087ac --- /dev/null +++ b/docs/release/README.md @@ -0,0 +1 @@ +# Release Note Drafts diff --git a/docs/release/v0.3.0.md b/docs/release/v0.3.0.md new file mode 100644 index 0000000..b675d75 --- /dev/null +++ b/docs/release/v0.3.0.md @@ -0,0 +1,26 @@ +--- +title: v0.3.0 Release Notes +date: 2026-03-24 +status: draft +--- + +## Notes/Pre-Release Checklist + +- Authentication with AT Proto should be *removed* from this released (just not included + in the UI) as syncing is being deferred to 0.4.0. +- Everything has to be renamed to "Commonplace" from "Writer" minus the repo and website + (for now) + +--- + +## Added + +- Image support +- PDF exports now render images +- AT Proto Imports: blog posts from [Standard.Site](https://standard.site) and strings from [Tangled](https://tangled.org) + +## Fixed + +## Changed + +- Writer is now called Commonplace. diff --git a/docs/specs/integration/gh.md b/docs/specs/integration/gh.md index 0125566..fdab51e 100644 --- a/docs/specs/integration/gh.md +++ b/docs/specs/integration/gh.md @@ -1,8 +1,14 @@ --- title: GitHub Gist Integration Spec -updated: 2026-03-19 +updated: 2026-03-24 --- +## Implementation Status (2026-03-24) + +- Implemented: Part 1 public gist browse/import path (`gist_list_public`, `gist_get`) with frontend integration in the shared import sheet. +- Not implemented: Part 2 auth/private gist flow and Part 3 publish/update/delete/origin tracking. +- UX update: sidebar now uses a single `Import` menu button instead of three separate import buttons. + ## Goals - Browse and import public gists from any GitHub user. @@ -92,7 +98,7 @@ Register a GitHub OAuth App at `https://github.com/settings/developers`: │ │ ◄── CommandResponse │ + github.rs │ └──────────────┘ └────────┬──────────┘ │ - reqwest + token auth + octocrab + token auth │ ┌────────▼──────────┐ │ api.github.com │ diff --git a/docs/tasks/gh.md b/docs/tasks/gh.md index 9b5f295..a3e175f 100644 --- a/docs/tasks/gh.md +++ b/docs/tasks/gh.md @@ -1,11 +1,28 @@ --- title: "GitHub Gist Integration" -last_updated: 2026-03-20 +last_updated: 2026-03-24 --- Import public gists, read personal/secret gists, and publish documents as gists. Full spec in [docs/integration/gh.md](../integration/gh.md). +## Status (2026-03-24) + +- Part 1 is implemented end-to-end. +- Part 2 and Part 3 are not implemented yet. + +### Implemented now + +1. **Backend gist service** in `writer-core` using `octocrab`. +2. **Tauri commands** `gist_list_public` and `gist_get` wired and registered. +3. **Frontend import flow parity** in the shared AtProto import sheet: + - Tangled/GitHub mode switch + - username browsing for public gists + - gist list + detail fetch for preview/import + - destination picker + path + `doc_exists`/`doc_save` import flow +4. **Sidebar UX cleanup**: replaced three separate import buttons with one `Import` menu button that lists Tangled/GitHub/Standard.Site entries with icons. +5. **Tests and checks**: frontend lint/type/tests and Rust checks pass for the implemented scope. + ## Part 1 — Public gist browsing 1. **Backend gist module** — `src-tauri/src/github/{mod,gists}.rs` with `GithubState`, `GistRecord` diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d2e0fb1..537524d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, State}; use tauri_plugin_dialog::DialogExt; use writer_core::atproto::AtProtoState; +use writer_core::github::GithubState; use writer_core::scan_style_matches; use writer_core::{ AppError, BackendEvent, CommandResult, DocContent, DocId, DocListOptions, DocMeta, LocationDescriptor, LocationId, @@ -15,12 +16,14 @@ use writer_core::{ use writer_store::{SidebarTreeState, Store, StyleCheckSettings, UiLayoutSettings}; mod atproto; +mod github; mod images; mod md; mod standard_site; mod strings; pub use atproto::*; +pub use github::*; pub use images::*; pub use md::*; pub use standard_site::*; @@ -33,13 +36,20 @@ pub struct AppState { pub store: Arc, pub watchers: Mutex>, pub atproto: Arc, + pub github: Arc, } impl AppState { pub fn new(store: Store) -> Self { let app_dir = Store::default_app_dir().expect("store app dir should resolve"); let atproto = AtProtoState::new(&app_dir).expect("AT Protocol state should initialize"); - Self { store: Arc::new(store), watchers: Mutex::new(HashMap::new()), atproto: Arc::new(atproto) } + let github = GithubState::new(); + Self { + store: Arc::new(store), + watchers: Mutex::new(HashMap::new()), + atproto: Arc::new(atproto), + github: Arc::new(github), + } } } diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs new file mode 100644 index 0000000..77ad867 --- /dev/null +++ b/src-tauri/src/commands/github.rs @@ -0,0 +1,31 @@ +//! GitHub gist read commands + +use super::{AppState, CommandResponse}; +use tauri::State; +use writer_core::{github::GistRecord, CommandResult}; + +#[tauri::command] +pub async fn gist_list_public(state: State<'_, AppState>, username: String) -> CommandResponse> { + log::info!("Listing public GitHub gists"); + + match state.github.gist_list_public(&username).await { + Ok(records) => Ok(CommandResult::ok(records)), + Err(error) => { + log::error!("Failed to list public GitHub gists: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn gist_get(state: State<'_, AppState>, gist_id: String) -> CommandResponse { + log::info!("Fetching GitHub gist"); + + match state.github.gist_get(&gist_id).await { + Ok(record) => Ok(CommandResult::ok(record)), + Err(error) => { + log::error!("Failed to fetch GitHub gist: {}", error); + Ok(CommandResult::err(error)) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2432ac4..2dca0ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -100,6 +100,8 @@ pub fn run() { cmd::string_create, cmd::string_update, cmd::string_delete, + cmd::gist_list_public, + cmd::gist_get, cmd::app_version_get, cmd::location_add_via_dialog, cmd::location_list, diff --git a/src/__tests__/Sidebar.test.tsx b/src/__tests__/Sidebar.test.tsx index 7fa8c13..c86be25 100644 --- a/src/__tests__/Sidebar.test.tsx +++ b/src/__tests__/Sidebar.test.tsx @@ -148,19 +148,30 @@ describe("Sidebar", () => { expect(handleRefreshSidebar).toHaveBeenCalledWith(1); }); - it("shows import actions for Tangled strings and Standard.Site posts", () => { + it("shows import actions for Tangled strings, GitHub gists, and Standard.Site posts", () => { vi.mocked(useSidebarState).mockReturnValue(createSidebarState()); const onOpenImportSheet = vi.fn(); + const onOpenGithubImportSheet = vi.fn(); const onOpenStandardSiteImportSheet = vi.fn(); render( - , + , ); - fireEvent.click(screen.getByTitle("Import Tangled strings")); - fireEvent.click(screen.getByTitle("Import Standard.Site posts")); + fireEvent.click(screen.getByTitle("Import")); + fireEvent.click(screen.getByRole("menuitem", { name: "Tangled Strings" })); + + fireEvent.click(screen.getByTitle("Import")); + fireEvent.click(screen.getByRole("menuitem", { name: "GitHub Gists" })); + + fireEvent.click(screen.getByTitle("Import")); + fireEvent.click(screen.getByRole("menuitem", { name: "Standard.Site Posts" })); expect(onOpenImportSheet).toHaveBeenCalledOnce(); + expect(onOpenGithubImportSheet).toHaveBeenCalledOnce(); expect(onOpenStandardSiteImportSheet).toHaveBeenCalledOnce(); }); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 6572315..1653204 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -76,6 +76,8 @@ type WorkspacePanelPropOverrides = { statusBar?: Partial; diagnostics?: Partial; welcome?: Partial>; + activeDocRelPath?: WorkspacePanelProps["activeDocRelPath"]; + activeDocContentType?: WorkspacePanelProps["activeDocContentType"]; }; const createSidebarState = (overrides: Partial = {}): SidebarStateReturn => { @@ -303,6 +305,8 @@ const createWorkspacePanelProps = (overrides: WorkspacePanelPropOverrides = {}): onAddLocation: vi.fn(), ...overrides.welcome, }, + activeDocRelPath: overrides.activeDocRelPath, + activeDocContentType: overrides.activeDocContentType, }); const renderWorkspacePanel = ( diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts index 3faab86..66fd559 100644 --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -12,6 +12,8 @@ import { docOpen, docSave, err, + gistGet, + gistListPublic, globalCaptureGet, globalCaptureOpen, globalCapturePause, @@ -237,6 +239,23 @@ describe("command Builders", () => { payload: { didOrHandle: "alice.bsky.social", tid: "3lxyz" }, }); }); + + it("should create GitHub gist list and get commands", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + + expect(gistListPublic("octocat", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "gist_list_public", + payload: { username: "octocat" }, + }); + + expect(gistGet("aa5a315d61ae9438b18d", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "gist_get", + payload: { gistId: "aa5a315d61ae9438b18d" }, + }); + }); }); describe(startWatch, () => { diff --git a/src/__tests__/useAtProtoController.test.tsx b/src/__tests__/useAtProtoController.test.tsx index c60f0c2..434f30b 100644 --- a/src/__tests__/useAtProtoController.test.tsx +++ b/src/__tests__/useAtProtoController.test.tsx @@ -1,4 +1,4 @@ -import { toImportMarkdown } from "$hooks/controllers/useAtProtoController"; +import { toGistImportMarkdown, toImportMarkdown } from "$hooks/controllers/useAtProtoController"; import { stringCreate, stringDelete, stringUpdate } from "$ports"; import { describe, expect, it } from "vitest"; @@ -43,6 +43,44 @@ describe("toImportMarkdown", () => { }); }); +describe("toGistImportMarkdown", () => { + it("passes markdown gist content through unchanged", () => { + expect( + toGistImportMarkdown({ + id: "1", + filename: "README.md", + description: "", + contents: "# Gist", + language: "Markdown", + public: true, + htmlUrl: "https://gist.github.com/example/1", + owner: "octocat", + createdAt: "2026-03-19T10:00:00Z", + updatedAt: "2026-03-20T10:00:00Z", + fileCount: 1, + }), + ).toBe("# Gist"); + }); + + it("wraps non-markdown gist content in fenced blocks using gist language", () => { + expect( + toGistImportMarkdown({ + id: "2", + filename: "script.rb", + description: "", + contents: "puts 'hello'", + language: "Ruby", + public: true, + htmlUrl: "https://gist.github.com/example/2", + owner: "octocat", + createdAt: "2026-03-19T10:00:00Z", + updatedAt: "2026-03-20T10:00:00Z", + fileCount: 1, + }), + ).toBe("```ruby\nputs 'hello'\n```\n"); + }); +}); + const noop = () => {}; describe("stringCreate command builder", () => { diff --git a/src/components/AppLayout/WorkspacePanel.tsx b/src/components/AppLayout/WorkspacePanel.tsx index f022d81..393d96a 100644 --- a/src/components/AppLayout/WorkspacePanel.tsx +++ b/src/components/AppLayout/WorkspacePanel.tsx @@ -86,6 +86,7 @@ export type WorkspacePanelProps = { >; onOpenWelcomeTab?: () => void; onOpenImportSheet?: () => void; + onOpenGithubImportSheet?: () => void; onOpenStandardSiteImportSheet?: () => void; editor: WorkspaceEditorProps; preview: WorkspacePreviewProps; @@ -298,6 +299,7 @@ export function WorkspacePanel( toolbar, onOpenWelcomeTab, onOpenImportSheet, + onOpenGithubImportSheet, onOpenStandardSiteImportSheet, editor, preview, @@ -437,6 +439,7 @@ export function WorkspacePanel(
; type ImportSheetProps = { controller: Controller; isOpen: boolean; onClose: () => void; onBack?: () => void }; -function ImportSheetTitle() { +function ImportSheetTitle({ source }: { source: Controller["importState"]["source"] }) { + const isGithub = source === "github"; return (
- -

Import from Tangled

+ {isGithub ? : } +

+ {isGithub ? "Import from GitHub Gists" : "Import from Tangled"} +

); } -function ImportSheetHeader({ onBack }: { onBack?: () => void }) { +function ImportSheetHeader({ controller, onBack }: { controller: Controller; onBack?: () => void }) { + const isGithub = controller.importState.source === "github"; return ( -
+
- +

- Browse any public Tangled handle, preview a string, and save it into one of your locations. + {isGithub + ? "Browse public gists by username, preview content, and save into one of your locations." + : "Browse any public Tangled handle, preview a string, and save it into one of your locations."}

{onBack && } @@ -34,90 +40,182 @@ function ImportSheetHeader({ onBack }: { onBack?: () => void }) { ); } +function ImportSheetTabs({ controller }: { controller: Controller }) { + const isGithub = controller.importState.source === "github"; + const isTangled = !isGithub; + + const handleSelectTangled = useCallback(() => { + controller.setImportSource("tangled"); + }, [controller]); + + const handleSelectGithub = useCallback(() => { + controller.setImportSource("github"); + }, [controller]); + + return ( +
+ + +
+ ); +} + function RecordRow( - { record, isSelected, onSelectTid }: { - record: Controller["importState"]["records"][number]; + { record, isSelected, onSelectTid, accentClass }: { + record: { id: string; filename: string; description: string; createdAt: string; visibility?: string }; isSelected: boolean; - onSelectTid: (tid: string) => void; + onSelectTid: (id: string) => void; + accentClass: string; }, ) { const handleClick = useCallback(() => { - onSelectTid(record.tid); - }, [onSelectTid, record.tid]); + onSelectTid(record.id); + }, [onSelectTid, record.id]); return ( ); } function BrowseHandleForm({ controller }: { controller: Controller }) { + const isGithub = controller.importState.source === "github"; + const handleChange = useCallback>((event) => { + if (isGithub) { + controller.setGithubUsername(event.target.value); + return; + } + controller.setImportHandle(event.target.value); - }, [controller]); + }, [controller, isGithub]); + + const handleBrowse = useCallback(() => { + if (isGithub) { + controller.handleBrowseGists(); + return; + } + + controller.handleBrowseStrings(); + }, [controller, isGithub]); + + const browseValue = isGithub ? controller.importState.githubUsername : controller.importState.handle; + const isBrowseDisabled = controller.importState.isListing || !browseValue.trim(); + const label = isGithub ? "GitHub username" : "Handle or DID"; + const placeholder = isGithub ? "octocat" : (controller.session?.handle ?? "alice.bsky.social"); return ( -
+

- Public strings can be imported without signing in. The browser defaults to your connected handle when available. + {isGithub + ? "Public gists can be imported without signing in. Select a gist to fetch full contents before import." + : "Public strings can be imported without signing in. The browser defaults to your connected handle when available."}

- {/* TODO: Add GitHub Gist import alongside Tangled browsing. */}
); } function RecordsPanel({ controller }: { controller: Controller }) { + const isGithub = controller.importState.source === "github"; + const handleSelectTid = useCallback((tid: string) => { + if (isGithub) { + controller.handleSelectGist(tid); + return; + } + controller.handleSelectString(tid); - }, [controller]); + }, [controller, isGithub]); + + const rows = isGithub + ? controller.importState.gists.map((gist) => ({ + id: gist.id, + filename: gist.filename, + description: gist.description, + createdAt: gist.createdAt, + visibility: gist.public ? "Public" : "Private", + })) + : controller.importState.records.map((record) => ({ + id: record.tid, + filename: record.filename, + description: record.description, + createdAt: record.createdAt, + })); + + const selectedId = isGithub ? controller.importState.selectedGistId : controller.importState.selectedTid; + const panelTitle = isGithub + ? (controller.importState.browseUsername ? `Gists for ${controller.importState.browseUsername}` : "Gists") + : (controller.importState.browseHandle ? `Strings for ${controller.importState.browseHandle}` : "Strings"); return ( -
-
- {controller.importState.browseHandle ? `Strings for ${controller.importState.browseHandle}` : "Strings"} +
+
+ {panelTitle}
-
- {controller.importState.records.length === 0 +
+ {rows.length === 0 ? (
{controller.importState.isListing - ? "Loading Tangled strings..." - : "No strings loaded yet. Enter a handle and browse."} + ? (isGithub ? "Loading GitHub gists..." : "Loading Tangled strings...") + : (isGithub + ? "No gists loaded yet. Enter a username and browse." + : "No strings loaded yet. Enter a handle and browse.")}
) - : controller.importState.records.map((record) => ( + : rows.map((record) => ( ))}
@@ -150,6 +248,8 @@ function LocationSelector({ importState, handleChange, hasLocations, locations } } function ImportDestinationForm({ controller }: { controller: Controller }) { + const isGithub = controller.importState.source === "github"; + const handleLocationChange = useCallback>((event) => { controller.setDestinationLocationId(Number(event.target.value) || null); }, [controller]); @@ -165,13 +265,14 @@ function ImportDestinationForm({ controller }: { controller: Controller }) { const importDisabled = useMemo( () => controller.importState.isSaving - || !controller.importState.selectedRecord + || (!controller.importState.selectedRecord && !controller.importState.selectedGist) || !controller.importState.destinationLocationId || !controller.importState.destinationRelPath.trim() || !controller.hasLocations, [ controller.importState.isSaving, controller.importState.selectedRecord, + controller.importState.selectedGist, controller.importState.destinationLocationId, controller.importState.destinationRelPath, controller.hasLocations, @@ -179,8 +280,8 @@ function ImportDestinationForm({ controller }: { controller: Controller }) { ); return ( -
-
+
+
+ className="w-full rounded-lg border border-stroke-subtle bg-layer-01 px-3 py-2 text-sm text-text-primary outline-none transition focus:border-stroke-strong" />
-
+

- Non-Markdown strings are imported as fenced code blocks so the resulting document stays readable in Writer. + {isGithub + ? "Non-Markdown gists are imported as fenced code blocks so the resulting document stays readable in Writer." + : "Non-Markdown strings are imported as fenced code blocks so the resulting document stays readable in Writer."}

-
- -
+
); } function PreviewPanel({ controller }: { controller: Controller }) { + const isGithub = controller.importState.source === "github"; + return ( -
-
+
+
Preview
-
+      
         {controller.importState.isFetching
-          ? "Loading string preview..."
-          : controller.importState.previewText || "Select a string to preview the imported document body."}
+          ? (isGithub ? "Loading gist preview..." : "Loading string preview...")
+          : (controller.importState.previewText
+            || (isGithub
+              ? "Select a gist to preview the imported document body."
+              : "Select a string to preview the imported document body."))}
       
); } function SelectedRecordSummary({ controller }: { controller: Controller }) { - const selectedFilename = useMemo(() => controller.importState.selectedRecord?.filename ?? "Nothing selected", [ - controller.importState.selectedRecord, - ]); + const isGithub = controller.importState.source === "github"; + const selectedFilename = useMemo( + () => + (isGithub ? controller.importState.selectedGist?.filename : controller.importState.selectedRecord?.filename) + ?? "Nothing selected", + [controller.importState.selectedGist, controller.importState.selectedRecord, isGithub], + ); + const description = isGithub + ? (controller.importState.selectedGist?.description || "Select a gist to inspect its contents before import.") + : (controller.importState.selectedRecord?.description || "Select a string to inspect its contents before import."); return ( -
+
{selectedFilename}
-

- {controller.importState.selectedRecord?.description || "Select a string to inspect its contents before import."} -

+

{description}

); } function ImportSheetBody({ controller }: { controller: Controller }) { return ( -
-
-
+
+
+
-
+
@@ -269,7 +383,8 @@ export function ImportSheet({ controller, isOpen, onClose, onBack }: ImportSheet className="right-4 top-8 bottom-4 w-[min(96vw,1080px)] rounded-xl border shadow-xl" backdropClassName="bg-black/30">
- + +
diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 53a0e89..f6ab9de 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -1,4 +1,5 @@ import { Button } from "$components/Button"; +import { ContextMenu, type ContextMenuItem } from "$components/ContextMenu"; import { useSidebarActions } from "$hooks/controllers/useSidebarActions"; import { FileAddIcon, @@ -6,13 +7,14 @@ import { FolderAddIcon, FolderIcon, GithubIcon, + ImportIcon, RefreshIcon, StandardSiteIcon, Tangled, } from "$icons"; import { useSidebarState } from "$state/selectors"; import type { DocMeta } from "$types"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AddButton } from "./AddButton"; import { DocumentOperationDialog, @@ -32,6 +34,7 @@ const EMPTY_DIRECTORIES: string[] = []; export type SidebarProps = { onNewDocument?: (locationId?: number) => void; onOpenImportSheet?: () => void; + onOpenGithubImportSheet?: () => void; onOpenStandardSiteImportSheet?: () => void; }; @@ -73,25 +76,86 @@ const CountPill = ({ count, kind }: CountPillProps) => ( ); -type ImportButtonProps = { onClick: () => void; label: string; icon: "tangled" | "github" | "standardSite" }; - -const ImportButton = ({ onClick, label, icon }: ImportButtonProps) => ( - -); +type ImportMenuProps = { + onOpenImportSheet?: () => void; + onOpenGithubImportSheet?: () => void; + onOpenStandardSiteImportSheet?: () => void; +}; + +function ImportMenu({ onOpenImportSheet, onOpenGithubImportSheet, onOpenStandardSiteImportSheet }: ImportMenuProps) { + const wrapperRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const menuItems = useMemo(() => { + const items: ContextMenuItem[] = []; + + if (onOpenImportSheet) { + items.push({ + label: "Tangled Strings", + onClick: onOpenImportSheet, + icon: , + }); + } + + if (onOpenGithubImportSheet) { + items.push({ label: "GitHub Gists", onClick: onOpenGithubImportSheet, icon: }); + } -export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteImportSheet }: SidebarProps) { + if (onOpenStandardSiteImportSheet) { + items.push({ label: "Standard.Site Posts", onClick: onOpenStandardSiteImportSheet, icon: }); + } + + return items; + }, [onOpenGithubImportSheet, onOpenImportSheet, onOpenStandardSiteImportSheet]); + + const handleOpenMenu = useCallback(() => { + if (!wrapperRef.current || menuItems.length === 0) { + return; + } + + const rect = wrapperRef.current.getBoundingClientRect(); + setPosition({ x: rect.left, y: rect.top - 4 }); + setIsOpen((prev) => !prev); + }, [menuItems.length]); + + const handleCloseMenu = useCallback(() => { + setIsOpen(false); + }, []); + + if (menuItems.length === 0) { + return null; + } + + return ( +
+ + + +
+ ); +} + +export function Sidebar( + { onNewDocument, onOpenImportSheet, onOpenGithubImportSheet, onOpenStandardSiteImportSheet }: SidebarProps, +) { const { handleAddLocation, handleRemoveLocation, @@ -360,15 +424,10 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm
- {onOpenStandardSiteImportSheet && ( - - )} - {onOpenImportSheet && ( - - )} +
diff --git a/src/components/StandardSite/PostImportSheet.tsx b/src/components/StandardSite/PostImportSheet.tsx index 47e6b8b..4030377 100644 --- a/src/components/StandardSite/PostImportSheet.tsx +++ b/src/components/StandardSite/PostImportSheet.tsx @@ -15,7 +15,7 @@ function SheetTitle() { function SheetHeader({ onBack }: { onBack?: () => void }) { return ( -
+
@@ -44,7 +44,7 @@ function BrowseHandleForm({ controller }: { controller: Controller }) { }, [controller]); return ( -
+