diff --git a/Cargo.lock b/Cargo.lock index 79840104..3545f3a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2074,8 +2074,12 @@ name = "loc-cli" version = "0.3.6" dependencies = [ "clap", + "locality-browser", + "locality-confluence", "locality-connector", "locality-core", + "locality-github", + "locality-gitlab", "locality-gmail", "locality-google-calendar", "locality-google-docs", @@ -2091,6 +2095,16 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "locality-browser" +version = "0.3.6" +dependencies = [ + "locality-connector", + "locality-core", + "serde", + "serde_json", +] + [[package]] name = "locality-cloud-files" version = "0.3.6" @@ -2108,6 +2122,19 @@ dependencies = [ "windows", ] +[[package]] +name = "locality-confluence" +version = "0.3.6" +dependencies = [ + "base64 0.22.1", + "locality-connector", + "locality-core", + "reqwest", + "rustls", + "serde", + "serde_json", +] + [[package]] name = "locality-connector" version = "0.3.6" @@ -2132,8 +2159,11 @@ name = "locality-desktop" version = "0.3.6" dependencies = [ "loc-cli", + "locality-browser", + "locality-confluence", "locality-connector", "locality-core", + "locality-gitlab", "locality-gmail", "locality-google-calendar", "locality-google-docs", @@ -2170,6 +2200,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "locality-github" +version = "0.3.6" +dependencies = [ + "base64 0.22.1", + "locality-connector", + "locality-core", + "reqwest", + "rustls", + "serde", + "serde_json", +] + +[[package]] +name = "locality-gitlab" +version = "0.3.6" +dependencies = [ + "base64 0.22.1", + "locality-connector", + "locality-core", + "reqwest", + "rustls", + "serde", + "serde_json", +] + [[package]] name = "locality-gmail" version = "0.3.6" @@ -2253,6 +2309,14 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "locality-planned-connectors" +version = "0.3.6" +dependencies = [ + "locality-connector", + "locality-core", +] + [[package]] name = "locality-platform" version = "0.3.6" @@ -2294,14 +2358,19 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.3.4", + "locality-browser", + "locality-confluence", "locality-connector", "locality-core", + "locality-github", + "locality-gitlab", "locality-gmail", "locality-google-calendar", "locality-google-docs", "locality-granola", "locality-linear", "locality-notion", + "locality-planned-connectors", "locality-platform", "locality-slack", "locality-store", diff --git a/Cargo.toml b/Cargo.toml index a0acf5c8..51852129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,14 @@ members = [ "crates/locality-google-docs", "crates/locality-google-calendar", "crates/locality-gmail", + "crates/locality-confluence", + "crates/locality-browser", + "crates/locality-github", + "crates/locality-gitlab", "crates/locality-granola", "crates/locality-linear", "crates/locality-slack", + "crates/locality-planned-connectors", "platform/linux/locality-fuse", "platform/windows/locality-cloud-files", ] @@ -34,6 +39,11 @@ locality-notion = { path = "crates/locality-notion" } locality-google-docs = { path = "crates/locality-google-docs" } locality-google-calendar = { path = "crates/locality-google-calendar" } locality-gmail = { path = "crates/locality-gmail" } +locality-confluence = { path = "crates/locality-confluence" } +locality-browser = { path = "crates/locality-browser" } +locality-github = { path = "crates/locality-github" } +locality-gitlab = { path = "crates/locality-gitlab" } locality-granola = { path = "crates/locality-granola" } locality-linear = { path = "crates/locality-linear" } locality-slack = { path = "crates/locality-slack" } +locality-planned-connectors = { path = "crates/locality-planned-connectors" } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c69750e0..bfbaf84f 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -13,9 +13,12 @@ tauri-build = { version = "2", features = [] } loc-cli = { path = "../../../crates/loc-cli" } locality-connector.workspace = true locality-core.workspace = true +locality-browser.workspace = true +locality-confluence.workspace = true locality-gmail.workspace = true locality-google-calendar.workspace = true locality-google-docs.workspace = true +locality-gitlab.workspace = true locality-notion.workspace = true locality-platform.workspace = true locality-slack.workspace = true diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 38a1e040..dbff8c04 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -21,9 +21,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[cfg(test)] use loc_cli::connect::DEFAULT_NOTION_PROFILE_ID; use loc_cli::connect::{ - BrokerOAuthConnectOptions, ConnectOptions, GmailBrokerOAuthConnectOptions, - GoogleCalendarBrokerOAuthConnectOptions, GoogleDocsBrokerOAuthConnectOptions, - HttpGranolaConnectionProbe, HttpLinearConnectionProbe, SlackBrokerOAuthConnectOptions, + BrokerOAuthConnectOptions, ConfluenceConnectOptions, ConnectOptions, + GmailBrokerOAuthConnectOptions, GoogleCalendarBrokerOAuthConnectOptions, + GoogleDocsBrokerOAuthConnectOptions, HttpConfluenceConnectionProbe, HttpGitHubConnectionProbe, + HttpGitLabConnectionProbe, HttpGranolaConnectionProbe, HttpLinearConnectionProbe, + SlackBrokerOAuthConnectOptions, run_connect_confluence, run_connect_github, run_connect_gitlab, run_connect_gmail_broker_oauth, run_connect_google_calendar_broker_oauth, run_connect_google_docs_broker_oauth, run_connect_granola, run_connect_linear, run_connect_notion_broker_oauth, run_connect_slack_broker_oauth, run_disconnect, @@ -52,6 +54,8 @@ use loc_cli::search::{ run_search_with_access_roots, source_url_host, }; use loc_cli::status::{StatusOptions, StatusState, StatusSyncState, run_status}; +use locality_browser::{BROWSER_CONNECTOR_ID, BrowserMountSettings}; +use locality_confluence::CONFLUENCE_CONNECTOR_ID; #[cfg(test)] use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::OAuthBrokerStart; @@ -63,6 +67,7 @@ use locality_core::freshness::RemoteVersion; use locality_core::hydration::{HydrationReason, HydrationRequest}; use locality_core::journal::{JournalEntry, JournalStatus}; use locality_core::model::{EntityKind, HydrationState, MountId, RemoteId, TreeEntry}; +use locality_gitlab::GITLAB_CONNECTOR_ID; use locality_gmail::{ DEFAULT_GMAIL_OAUTH_BROKER_URL, DEFAULT_GMAIL_OAUTH_REDIRECT_URI, GMAIL_CONNECTOR_ID, HttpGmailOAuthBrokerClient, @@ -444,6 +449,7 @@ struct CreateDesktopMountRequest { read_only: bool, notion_root_page: Option, google_docs_workspace_folder: Option, + browser_capture_root: Option, } #[derive(Clone, Deserialize)] @@ -1424,6 +1430,7 @@ async fn create_workspace_mount(app: AppHandle, path: String) -> ActionReport { read_only: false, notion_root_page: None, google_docs_workspace_folder: None, + browser_capture_root: None, }, ) .await @@ -1462,6 +1469,55 @@ async fn connect_linear(app: AppHandle, api_key: String) -> ActionReport { report } +#[tauri::command] +async fn connect_github(app: AppHandle, api_key: String) -> ActionReport { + let report = tauri::async_runtime::spawn_blocking(move || connect_github_blocking(api_key)) + .await + .map_err(|error| format!("GitHub connection worker failed: {error}")) + .and_then(|result| result) + .map(|message| ActionReport { ok: true, message }) + .unwrap_or_else(|message| ActionReport { ok: false, message }); + if report.ok { + refresh_desktop_surfaces(&app); + } + report +} + +#[tauri::command] +async fn connect_gitlab(app: AppHandle, api_key: String) -> ActionReport { + let report = tauri::async_runtime::spawn_blocking(move || connect_gitlab_blocking(api_key)) + .await + .map_err(|error| format!("GitLab connection worker failed: {error}")) + .and_then(|result| result) + .map(|message| ActionReport { ok: true, message }) + .unwrap_or_else(|message| ActionReport { ok: false, message }); + if report.ok { + refresh_desktop_surfaces(&app); + } + report +} + +#[tauri::command] +async fn connect_confluence( + app: AppHandle, + site_url: String, + email: String, + api_token: String, +) -> ActionReport { + let report = tauri::async_runtime::spawn_blocking(move || { + connect_confluence_blocking(site_url, email, api_token) + }) + .await + .map_err(|error| format!("Confluence connection worker failed: {error}")) + .and_then(|result| result) + .map(|message| ActionReport { ok: true, message }) + .unwrap_or_else(|message| ActionReport { ok: false, message }); + if report.ok { + refresh_desktop_surfaces(&app); + } + report +} + fn connect_granola_blocking(api_key: String) -> Result { let api_key = api_key.trim().to_string(); if api_key.is_empty() { @@ -1510,6 +1566,7 @@ fn connect_granola_blocking(api_key: String) -> Result { read_only: !desktop_mount_is_editable_by_default("granola"), notion_root_page: None, google_docs_workspace_folder: None, + browser_capture_root: None, }) } @@ -1561,6 +1618,178 @@ fn connect_linear_blocking(api_key: String) -> Result { read_only: !desktop_mount_is_editable_by_default("linear"), notion_root_page: None, google_docs_workspace_folder: None, + browser_capture_root: None, + }) +} + +fn connect_github_blocking(api_key: String) -> Result { + let api_key = api_key.trim().to_string(); + if api_key.is_empty() { + return Err("Enter a GitHub personal access token.".to_string()); + } + let state_root = default_state_root(); + let mut store = SqliteStateStore::open(state_root.clone()) + .map_err(|error| format!("Could not open Locality state: {error}"))?; + let credentials = open_credential_store(&state_root); + let report = run_connect_github( + &mut store, + credentials.as_ref(), + ConnectOptions { + connection_id: Some(ConnectionId::new("github-default")), + token: api_key, + }, + &HttpGitHubConnectionProbe, + ) + .map_err(|error| error.message())?; + let existing_mount = store + .get_mount(&MountId::new("github-main")) + .map_err(|error| format!("Could not inspect GitHub mount: {error}"))? + .filter(|mount| mount.connector == "github"); + drop(store); + if let Some(mount) = existing_mount { + ensure_daemon_running(&state_root)?; + reload_daemon_mounts(&state_root)?; + let mut message = "Reconnected the existing GitHub source.".to_string(); + if mount.projection.uses_virtual_filesystem() { + if let Err(error) = activate_virtual_projection_mount(&state_root, &mount, true) { + if recoverable_macos_file_provider_activation_error(&error) { + append_macos_file_provider_activation_warning(&mut message, &error); + } else { + return Err(error); + } + } + } + return Ok(message); + } + + create_desktop_mount_blocking(CreateDesktopMountRequest { + connector: "github".to_string(), + path: "github".to_string(), + mount_id: "github-main".to_string(), + connection_id: Some(report.connection_id), + read_only: !desktop_mount_is_editable_by_default("github"), + notion_root_page: None, + google_docs_workspace_folder: None, + browser_capture_root: None, + }) +} + +fn connect_gitlab_blocking(api_key: String) -> Result { + let api_key = api_key.trim().to_string(); + if api_key.is_empty() { + return Err("Enter a GitLab personal access token.".to_string()); + } + let state_root = default_state_root(); + let mut store = SqliteStateStore::open(state_root.clone()) + .map_err(|error| format!("Could not open Locality state: {error}"))?; + let credentials = open_credential_store(&state_root); + let report = run_connect_gitlab( + &mut store, + credentials.as_ref(), + ConnectOptions { + connection_id: Some(ConnectionId::new("gitlab-default")), + token: api_key, + }, + &HttpGitLabConnectionProbe, + ) + .map_err(|error| error.message())?; + let existing_mount = store + .get_mount(&MountId::new("gitlab-main")) + .map_err(|error| format!("Could not inspect GitLab mount: {error}"))? + .filter(|mount| mount.connector == GITLAB_CONNECTOR_ID); + drop(store); + if let Some(mount) = existing_mount { + ensure_daemon_running(&state_root)?; + reload_daemon_mounts(&state_root)?; + let mut message = "Reconnected the existing GitLab source.".to_string(); + if mount.projection.uses_virtual_filesystem() { + if let Err(error) = activate_virtual_projection_mount(&state_root, &mount, true) { + if recoverable_macos_file_provider_activation_error(&error) { + append_macos_file_provider_activation_warning(&mut message, &error); + } else { + return Err(error); + } + } + } + return Ok(message); + } + + create_desktop_mount_blocking(CreateDesktopMountRequest { + connector: GITLAB_CONNECTOR_ID.to_string(), + path: "gitlab".to_string(), + mount_id: "gitlab-main".to_string(), + connection_id: Some(report.connection_id), + read_only: !desktop_mount_is_editable_by_default(GITLAB_CONNECTOR_ID), + notion_root_page: None, + google_docs_workspace_folder: None, + browser_capture_root: None, + }) +} + +fn connect_confluence_blocking( + site_url: String, + email: String, + api_token: String, +) -> Result { + let site_url = site_url.trim().trim_end_matches('/').to_string(); + let email = email.trim().to_string(); + let api_token = api_token.trim().to_string(); + if site_url.is_empty() { + return Err("Enter a Confluence site URL.".to_string()); + } + if email.is_empty() { + return Err("Enter your Atlassian account email.".to_string()); + } + if api_token.is_empty() { + return Err("Enter a Confluence API token.".to_string()); + } + + let state_root = default_state_root(); + let mut store = SqliteStateStore::open(state_root.clone()) + .map_err(|error| format!("Could not open Locality state: {error}"))?; + let credentials = open_credential_store(&state_root); + let report = run_connect_confluence( + &mut store, + credentials.as_ref(), + ConfluenceConnectOptions { + connection_id: Some(ConnectionId::new("confluence-default")), + site_url, + email, + api_token, + }, + &HttpConfluenceConnectionProbe, + ) + .map_err(|error| error.message())?; + let existing_mount = store + .get_mount(&MountId::new("confluence-main")) + .map_err(|error| format!("Could not inspect Confluence mount: {error}"))? + .filter(|mount| mount.connector == CONFLUENCE_CONNECTOR_ID); + drop(store); + if let Some(mount) = existing_mount { + ensure_daemon_running(&state_root)?; + reload_daemon_mounts(&state_root)?; + let mut message = "Reconnected the existing Confluence source.".to_string(); + if mount.projection.uses_virtual_filesystem() { + if let Err(error) = activate_virtual_projection_mount(&state_root, &mount, true) { + if recoverable_macos_file_provider_activation_error(&error) { + append_macos_file_provider_activation_warning(&mut message, &error); + } else { + return Err(error); + } + } + } + return Ok(message); + } + + create_desktop_mount_blocking(CreateDesktopMountRequest { + connector: CONFLUENCE_CONNECTOR_ID.to_string(), + path: "confluence".to_string(), + mount_id: "confluence-main".to_string(), + connection_id: Some(report.connection_id), + read_only: !desktop_mount_is_editable_by_default(CONFLUENCE_CONNECTOR_ID), + notion_root_page: None, + google_docs_workspace_folder: None, + browser_capture_root: None, }) } @@ -2222,6 +2451,7 @@ fn run_workspace_mount_onboarding_blocking( read_only: false, notion_root_page: None, google_docs_workspace_folder: None, + browser_capture_root: None, }) { Ok(message) => workspace_mount_onboarding_report( MacosWorkspaceMountOnboardingState::Created, @@ -7585,7 +7815,11 @@ fn create_desktop_mount_blocking(request: CreateDesktopMountRequest) -> Result Some(ConnectionId::new(connection_id.to_string())), None => preferred_connection_id_for_connector(&store, &connector)?, }; - let read_only = request.read_only || connector == "granola" || connector == SLACK_CONNECTOR_ID; + let read_only = request.read_only + || connector == BROWSER_CONNECTOR_ID + || connector == GITLAB_CONNECTOR_ID + || connector == "granola" + || connector == SLACK_CONNECTOR_ID; let remote_root_id = match connector.as_str() { "notion" => request .notion_root_page @@ -7623,7 +7857,13 @@ fn create_desktop_mount_blocking(request: CreateDesktopMountRequest) -> Result None, + GOOGLE_CALENDAR_CONNECTOR_ID + | BROWSER_CONNECTOR_ID + | "gmail" + | GITLAB_CONNECTOR_ID + | "granola" + | "linear" + | SLACK_CONNECTOR_ID => None, _ => unreachable!("unsupported desktop connector should be rejected before mount setup"), }; let preserved = if can_remount_existing_workspace { @@ -7631,7 +7871,25 @@ fn create_desktop_mount_blocking(request: CreateDesktopMountRequest) -> Result bool { | "google-docs" | GOOGLE_CALENDAR_CONNECTOR_ID | "gmail" + | BROWSER_CONNECTOR_ID + | CONFLUENCE_CONNECTOR_ID + | "github" + | GITLAB_CONNECTOR_ID | "granola" | "linear" | SLACK_CONNECTOR_ID @@ -7713,7 +7975,45 @@ fn desktop_mount_creation_supports_connector(connector: &str) -> bool { } fn desktop_mount_is_editable_by_default(connector: &str) -> bool { - !matches!(connector, "granola" | "slack") + !matches!( + connector, + BROWSER_CONNECTOR_ID + | CONFLUENCE_CONNECTOR_ID + | "github" + | GITLAB_CONNECTOR_ID + | "granola" + | "slack" + ) +} + +fn default_browser_capture_root() -> PathBuf { + let home = platform_user_home().unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(target_os = "macos")] + { + home.join("Library") + .join("Application Support") + .join("Locality") + .join("browser-captures") + } + + #[cfg(not(target_os = "macos"))] + { + home.join(".local") + .join("share") + .join("locality") + .join("browser-captures") + } +} + +fn resolve_browser_capture_root(value: &str) -> PathBuf { + match expand_tilde(value) { + Ok(path) if path.is_absolute() => path, + Ok(path) => platform_user_home() + .unwrap_or_else(|| PathBuf::from(".")) + .join(path), + Err(_) => PathBuf::from(value), + } } fn desktop_mount_by_id(store: &SqliteStateStore, mount_id: &str) -> Result { @@ -7727,6 +8027,9 @@ fn preferred_connection_id_for_connector( store: &SqliteStateStore, connector: &str, ) -> Result, String> { + if connector == BROWSER_CONNECTOR_ID { + return Ok(None); + } if connector == "notion" { return preferred_notion_connection_id(store); } @@ -12786,6 +13089,7 @@ mod tests { read_only: false, notion_root_page: Some("should-not-be-used".to_string()), google_docs_workspace_folder: Some("should-not-be-used".to_string()), + browser_capture_root: None, }); if let Err(error) = &result { @@ -15046,6 +15350,16 @@ mod tests { )); assert!(super::desktop_mount_creation_supports_connector("linear")); assert!(super::desktop_mount_is_editable_by_default("linear")); + assert!(super::desktop_mount_creation_supports_connector("browser")); + assert!(!super::desktop_mount_is_editable_by_default("browser")); + assert!(super::desktop_mount_creation_supports_connector("github")); + assert!(!super::desktop_mount_is_editable_by_default("github")); + assert!(super::desktop_mount_creation_supports_connector( + "confluence" + )); + assert!(!super::desktop_mount_is_editable_by_default("confluence")); + assert!(super::desktop_mount_creation_supports_connector("gitlab")); + assert!(!super::desktop_mount_is_editable_by_default("gitlab")); assert!(super::desktop_mount_creation_supports_connector("slack")); assert!(!super::desktop_mount_is_editable_by_default("slack")); } @@ -18046,6 +18360,9 @@ fn main() { create_desktop_mount, connect_granola, connect_linear, + connect_github, + connect_gitlab, + connect_confluence, reset_source_state, disconnect_source, export_source_backup, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 067b36cd..6ad50d90 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -23,6 +23,7 @@ import { Minus, Monitor, Moon, + Plug, Plus, Power, RefreshCw, @@ -98,8 +99,14 @@ import { import { connectedSourcesReadyToMount, isSourceConnectorId, + plannedSourceConnectorDefinitions, + sourceConnectorCatalogDefinitions, + sourceConnectorDefaultMountDirectory as sourceConnectorDefaultMountDirectoryForId, + sourceConnectorDefaultMountId, + sourceConnectorDefinitions, sourceConnectionReady, sourceConnectorIds, + sourceConnectorName, sourceMountRetryOutcome, sourceMounted, sourceRequiresApiKey, @@ -107,7 +114,13 @@ import { sourceSetupIsActiveConnector, sourceSetupIsBusy, sourceSetupProgressLabel, + sourceUsesLocalFolder, + type ApiKeySourceConnectorId, + type SourceCatalogConnectorId, type SourceConnectorId, + type SourceConnectorAuthMode, + type SourceConnectorCategory, + type SourceConnectorAvailability, type SourceSetupState, } from "./source-setup"; import gmailIconUrl from "./assets/connectors/gmail.svg"; @@ -138,15 +151,20 @@ type AppTheme = "system" | "light" | "dark"; const APP_THEME_STORAGE_KEY = "locality.desktop.theme"; type ConnectorOption = { - id: SourceConnectorId; + id: SourceCatalogConnectorId; name: string; description: string; status: string; keywords: string[]; + availability: SourceConnectorAvailability; + category: SourceConnectorCategory; + authModes: readonly SourceConnectorAuthMode[]; + projection: string; + writeModel: string; mounted: boolean; }; -const CONNECTOR_ICON_URLS: Record = { +const CONNECTOR_ICON_URLS: Partial> = { notion: notionIconUrl, "google-docs": googleDocsIconUrl, "google-calendar": googleCalendarIconUrl, @@ -726,6 +744,14 @@ function suggestedAgentPrompt(mountPath: string, connector: OnboardingConnectorI return `Use Locality to inspect my Google Calendar source. Open the files under ${mountPath}, review calendar events with normal file tools, and prepare new event drafts for Locality review before creating them.`; case "gmail": return `Use Locality to inspect my Gmail source. Open the files under ${mountPath}, search mail with normal file tools, and prepare draft updates only when the mounted draft files support it. Leave outbound changes for Locality review.`; + case "browser": + return `Use Locality to inspect my saved browser sessions. Open the files under ${mountPath}, search captured pages with normal file tools, and cite the browser files you used. Browser captures are read-only in Locality, so do not try to push edits back.`; + case "confluence": + return `Use Locality to inspect my Confluence spaces. Open the files under ${mountPath}, search spaces and pages with normal file tools, and cite the Confluence files you used. Confluence is read-only in Locality, so do not try to push edits back.`; + case "github": + return `Use Locality to inspect my GitHub repositories. Open the files under ${mountPath}, search README files, issues, and pull requests with normal file tools, and cite the GitHub files you used. GitHub is read-only in Locality, so make repository edits in a normal git checkout.`; + case "gitlab": + return `Use Locality to inspect my GitLab projects. Open the files under ${mountPath}, search README files, issues, and merge requests with normal file tools, and cite the GitLab files you used. GitLab is read-only in Locality, so make repository edits in a normal git checkout.`; case "linear": return `Use Locality to edit my Linear issues. Open the files under ${mountPath}, update issue Markdown and editable frontmatter, and leave changes pending for Locality review before pushing.`; case "notion": @@ -748,7 +774,7 @@ function onboardingConnectorFromSnapshot(snapshot: DesktopSnapshot): OnboardingC } function connectorUsesOAuth(connector: OnboardingConnectorId) { - return !sourceRequiresApiKey(connector); + return !sourceRequiresApiKey(connector) && !sourceUsesLocalFolder(connector); } function connectorSkipsMountStep(connector: OnboardingConnectorId) { @@ -787,6 +813,14 @@ function onboardingConnectorDescription( return "Google Calendar is ready. Locality mounted primary calendar events as local files under CloudStorage."; case "gmail": return "Gmail is ready. Locality mounted mailboxes as local files under CloudStorage."; + case "browser": + return "Browser captures are ready. Locality mounted saved sessions and captured tabs as read-only local files."; + case "confluence": + return "Confluence is ready. Locality mounted spaces and pages as read-only local files under CloudStorage."; + case "github": + return "GitHub is ready. Locality mounted repositories, README files, issues, and pull requests as read-only local context."; + case "gitlab": + return "GitLab is ready. Locality mounted projects, README files, issues, and merge requests as read-only local context."; case "granola": return "Granola is ready. Locality mounted meeting summaries and transcripts as read-only files under CloudStorage."; case "linear": @@ -806,6 +840,14 @@ function onboardingConnectorDescription( return "A browser window is open. Approve Google Calendar access, then Locality will create the local calendar folder."; case "gmail": return "A browser window is open. Approve Gmail access, then Locality will create the local mailbox folder."; + case "browser": + return "Locality is preparing the local browser capture folder."; + case "confluence": + return "Locality is validating the Confluence site, account email, and API token before creating a read-only folder."; + case "github": + return "Locality is validating the personal access token and creating a read-only GitHub folder."; + case "gitlab": + return "Locality is validating the personal access token and creating a read-only GitLab folder."; case "granola": return "Locality is validating the API key and creating a read-only Granola folder."; case "linear": @@ -824,6 +866,14 @@ function onboardingConnectorDescription( return "Connect Google Calendar during setup so agents can review events and prepare new event drafts through local files."; case "gmail": return "Connect Gmail during setup so agents can search mailboxes and prepare reviewed draft work from local files."; + case "browser": + return "Choose a local capture folder to mount saved browser sessions and tabs as read-only files for agents."; + case "confluence": + return "Enter your Confluence Cloud site URL, Atlassian account email, and API token to mount spaces and pages as local read-only files."; + case "github": + return "Paste a GitHub personal access token to mount repositories, README files, issues, and pull requests as local read-only context."; + case "gitlab": + return "Paste a GitLab personal access token to mount projects, README files, issues, and merge requests as local read-only context."; case "granola": return "Paste a Granola API key to mount meeting summaries and transcripts as local read-only files. Keys are stored in your local credential store."; case "linear": @@ -843,6 +893,14 @@ function onboardingConnectorPills(connector: OnboardingConnectorId) { return ["Google OAuth", "Primary calendar", "Event drafts"]; case "gmail": return ["Google OAuth", "Mailbox files", "Draft review"]; + case "browser": + return ["Local folder", "Read-only", "Saved tabs"]; + case "confluence": + return ["API token", "Read-only", "Spaces and pages"]; + case "github": + return ["Personal token", "Read-only", "Repos and PRs"]; + case "gitlab": + return ["Personal token", "Read-only", "Projects and MRs"]; case "granola": return ["Read-only", "Meeting summaries", "Transcripts"]; case "linear": @@ -862,6 +920,14 @@ function onboardingReadyCopy(connector: OnboardingConnectorId) { return "Your Google Calendar source is ready as local files. Agents can review events and prepare new event drafts before anything is created remotely."; case "gmail": return "Your Gmail source is ready as local files. Agents can search mailbox content and prepare reviewed draft work without leaving the filesystem."; + case "browser": + return "Your saved browser sessions are ready as local read-only files. Agents can search captured pages without keeping every tab open."; + case "confluence": + return "Your Confluence spaces are ready as local read-only files. Agents can search pages and space summaries with normal file tools, while Locality keeps Confluence protected from edits."; + case "github": + return "Your GitHub repositories are ready as local read-only context. Agents can search README files, issues, and pull requests with normal file tools, while repository edits stay in a normal git checkout."; + case "gitlab": + return "Your GitLab projects are ready as local read-only context. Agents can search README files, issues, and merge requests with normal file tools, while repository edits stay in a normal git checkout."; case "granola": return "Your Granola meetings are ready as local read-only files. Agents can search summaries and transcripts with normal file tools, while Locality keeps the remote notes protected from edits."; case "linear": @@ -872,7 +938,7 @@ function onboardingReadyCopy(connector: OnboardingConnectorId) { } function onboardingPromptHint(connector: OnboardingConnectorId) { - return connector === "granola" || connector === "slack" + return connector === "browser" || connector === "github" || connector === "gitlab" || connector === "granola" || connector === "confluence" || connector === "slack" ? "Ask an agent to use the mounted read-only files." : "Claude and Codex are now set up to use Locality."; } @@ -1702,8 +1768,13 @@ function Onboarding({ const connector = onboardingConnectorFromSnapshot(snapshot); return connectionReady(snapshot) && !mountMissing(snapshot) ? connector : null; }); + const [githubApiKey, setGithubApiKey] = useState(""); + const [gitlabApiKey, setGitlabApiKey] = useState(""); const [granolaApiKey, setGranolaApiKey] = useState(""); const [linearApiKey, setLinearApiKey] = useState(""); + const [confluenceSiteUrl, setConfluenceSiteUrl] = useState(""); + const [confluenceEmail, setConfluenceEmail] = useState(""); + const [confluenceApiToken, setConfluenceApiToken] = useState(""); const [googleDocsWorkspaceFolder, setGoogleDocsWorkspaceFolder] = useState("Locality"); const [connectorConnecting, setConnectorConnecting] = useState(false); const [connectedWorkspace, setConnectedWorkspace] = useState(snapshot.connection.workspaceName); @@ -1740,7 +1811,18 @@ function Onboarding({ ); const selectedSourceName = sourceDisplayName(selectedOnboardingConnector); const selectedConnectorBusy = oauthInFlight || connectorConnecting; - const selectedApiKey = selectedOnboardingConnector === "linear" ? linearApiKey : granolaApiKey; + const selectedApiKey = selectedOnboardingConnector === "github" + ? githubApiKey + : selectedOnboardingConnector === "gitlab" + ? gitlabApiKey + : selectedOnboardingConnector === "confluence" + ? confluenceApiToken + : selectedOnboardingConnector === "linear" + ? linearApiKey + : granolaApiKey; + const selectedConfluenceCredentialsReady = Boolean( + confluenceSiteUrl.trim() && confluenceEmail.trim() && confluenceApiToken.trim(), + ); async function installAgentGuidance(path: string) { setAgentGuidanceState("installing"); @@ -2038,7 +2120,7 @@ function Onboarding({ path: sourceDefaultPath(snapshot, connector), mountId: sourceMountId(connector), connectionId: null, - readOnly: connector === "granola" || connector === "slack", + readOnly: connector === "github" || connector === "gitlab" || connector === "granola" || connector === "confluence" || connector === "slack", notionRootPage: null, googleDocsWorkspaceFolder: connector === "google-docs" ? googleDocsWorkspaceFolder.trim() || "Locality" @@ -2118,9 +2200,22 @@ function Onboarding({ case "slack": await connectOAuthOnboarding(selectedOnboardingConnector); return; + case "browser": + setOauthReady(true); + setStep(4); + return; case "granola": await connectGranolaOnboarding(); return; + case "confluence": + await connectConfluenceOnboarding(); + return; + case "github": + await connectGitHubOnboarding(); + return; + case "gitlab": + await connectGitLabOnboarding(); + return; case "linear": await connectLinearOnboarding(); } @@ -2140,18 +2235,41 @@ function Onboarding({ await connectApiKeyOnboarding("granola", granolaApiKey); } + async function connectConfluenceOnboarding() { + await connectApiKeyOnboarding("confluence", confluenceApiToken, { + siteUrl: confluenceSiteUrl, + email: confluenceEmail, + }); + } + + async function connectGitHubOnboarding() { + await connectApiKeyOnboarding("github", githubApiKey); + } + + async function connectGitLabOnboarding() { + await connectApiKeyOnboarding("gitlab", gitlabApiKey); + } + async function connectLinearOnboarding() { await connectApiKeyOnboarding("linear", linearApiKey); } - async function connectApiKeyOnboarding(connector: "granola" | "linear", apiKey: string) { + async function connectApiKeyOnboarding( + connector: ApiKeySourceConnectorId, + apiKey: string, + options?: { siteUrl?: string; email?: string }, + ) { const sourceName = sourceDisplayName(connector); if (connectorConnecting || !apiKey.trim()) { if (!apiKey.trim()) { - setOauthError(`Enter a ${sourceName} API key.`); + setOauthError(connector === "confluence" ? "Enter a Confluence API token." : `Enter a ${sourceName} API key.`); } return; } + if (connector === "confluence" && (!options?.siteUrl?.trim() || !options.email?.trim())) { + setOauthError("Enter your Confluence site URL and Atlassian account email."); + return; + } setOauthError(""); setLoginCopyMessage(""); @@ -2160,8 +2278,10 @@ function Onboarding({ setStep(3); try { const report = await callCommand( - connector === "linear" ? "connect_linear" : "connect_granola", - { apiKey }, + apiKeyConnectCommand(connector), + connector === "confluence" + ? { siteUrl: options?.siteUrl?.trim(), email: options?.email?.trim(), apiToken: apiKey } + : { apiKey }, { ok: true, message: `Connected demo ${sourceName} source.` }, ); if (!report.ok) { @@ -2511,19 +2631,60 @@ function Onboarding({ ]} /> )} - {sourceRequiresApiKey(selectedOnboardingConnector) && !connectionReadyNow && ( + {selectedOnboardingConnector === "confluence" && !connectionReadyNow && ( +
+ + + +
+ )} + {sourceRequiresApiKey(selectedOnboardingConnector) && selectedOnboardingConnector !== "confluence" && !connectionReadyNow && (