Git support (#143)

This commit is contained in:
Gregory Schier
2025-02-07 07:59:48 -08:00
committed by GitHub
parent cffc7714c1
commit 1a7c27663a
111 changed files with 4264 additions and 372 deletions

8
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"packages/plugin-runtime-types", "packages/plugin-runtime-types",
"packages/common-lib", "packages/common-lib",
"src-tauri/yaak-license", "src-tauri/yaak-license",
"src-tauri/yaak-git",
"src-tauri/yaak-models", "src-tauri/yaak-models",
"src-tauri/yaak-plugins", "src-tauri/yaak-plugins",
"src-tauri/yaak-sse", "src-tauri/yaak-sse",
@@ -3501,6 +3502,10 @@
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@yaakapp-internal/git": {
"resolved": "src-tauri/yaak-git",
"link": true
},
"node_modules/@yaakapp-internal/lib": { "node_modules/@yaakapp-internal/lib": {
"resolved": "packages/common-lib", "resolved": "packages/common-lib",
"link": true "link": true
@@ -15582,8 +15587,7 @@
}, },
"src-tauri/yaak-git": { "src-tauri/yaak-git": {
"name": "@yaakapp-internal/git", "name": "@yaakapp-internal/git",
"version": "1.0.0", "version": "1.0.0"
"extraneous": true
}, },
"src-tauri/yaak-license": { "src-tauri/yaak-license": {
"name": "@yaakapp-internal/license", "name": "@yaakapp-internal/license",

View File

@@ -11,6 +11,7 @@
"packages/plugin-runtime-types", "packages/plugin-runtime-types",
"packages/common-lib", "packages/common-lib",
"src-tauri/yaak-license", "src-tauri/yaak-license",
"src-tauri/yaak-git",
"src-tauri/yaak-models", "src-tauri/yaak-models",
"src-tauri/yaak-plugins", "src-tauri/yaak-plugins",
"src-tauri/yaak-sse", "src-tauri/yaak-sse",

92
src-tauri/Cargo.lock generated
View File

@@ -730,6 +730,10 @@ name = "cc"
version = "1.1.8" version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "504bdec147f2cc13c8b57ed9401fd8a147cc66b67ad5cb241394244f2c947549" checksum = "504bdec147f2cc13c8b57ed9401fd8a147cc66b67ad5cb241394244f2c947549"
dependencies = [
"jobserver",
"libc",
]
[[package]] [[package]]
name = "cesu8" name = "cesu8"
@@ -1986,6 +1990,21 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "git2"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fda788993cc341f69012feba8bf45c0ba4f3291fcc08e214b4d5a7332d88aff"
dependencies = [
"bitflags 2.6.0",
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
[[package]] [[package]]
name = "glib" name = "glib"
version = "0.18.5" version = "0.18.5"
@@ -2611,6 +2630,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "jpeg-decoder" name = "jpeg-decoder"
version = "0.3.1" version = "0.3.1"
@@ -2731,6 +2759,20 @@ version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "libgit2-sys"
version = "0.18.0+1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1a117465e7e1597e8febea8bb0c410f1c7fb93b1e1cddf34363f8390367ffec"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.7.4" version = "0.7.4"
@@ -2779,6 +2821,32 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "libssh2-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9b68e50e6e0b26f672573834882eb57759f6db9b3be2ea3c35c91188bb4eaa"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.14" version = "0.4.14"
@@ -3486,9 +3554,9 @@ dependencies = [
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.103" version = "0.9.105"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -7620,6 +7688,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"ts-rs", "ts-rs",
"uuid", "uuid",
"yaak-git",
"yaak-grpc", "yaak-grpc",
"yaak-http", "yaak-http",
"yaak-license", "yaak-license",
@@ -7631,6 +7700,25 @@ dependencies = [
"yaak-ws", "yaak-ws",
] ]
[[package]]
name = "yaak-git"
version = "0.1.0"
dependencies = [
"chrono",
"git2",
"log",
"openssl-sys",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-plugin",
"thiserror 2.0.11",
"ts-rs",
"yaak-models",
"yaak-sync",
]
[[package]] [[package]]
name = "yaak-grpc" name = "yaak-grpc"
version = "0.1.0" version = "0.1.0"

View File

@@ -1,5 +1,6 @@
[workspace] [workspace]
members = [ members = [
"yaak-git",
"yaak-grpc", "yaak-grpc",
"yaak-http", "yaak-http",
"yaak-license", "yaak-license",
@@ -37,7 +38,7 @@ objc = "0.2.7"
cocoa = "0.26.0" cocoa = "0.26.0"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9", features = ["vendored"] } # For Ubuntu installation to work openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[dependencies] [dependencies]
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
@@ -72,13 +73,14 @@ tokio = { version = "1.43.0", features = ["sync"] }
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
ts-rs = { workspace = true } ts-rs = { workspace = true }
uuid = "1.12.1" uuid = "1.12.1"
yaak-git = { path = "yaak-git" }
yaak-grpc = { path = "yaak-grpc" } yaak-grpc = { path = "yaak-grpc" }
yaak-http = { workspace = true } yaak-http = { workspace = true }
yaak-license = { path = "yaak-license" } yaak-license = { path = "yaak-license" }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-plugins = { workspace = true } yaak-plugins = { workspace = true }
yaak-sse = { workspace = true } yaak-sse = { workspace = true }
yaak-sync = { path = "yaak-sync" } yaak-sync = { workspace = true }
yaak-templates = { workspace = true } yaak-templates = { workspace = true }
yaak-ws = { path = "yaak-ws" } yaak-ws = { path = "yaak-ws" }
@@ -91,8 +93,9 @@ tauri-plugin = "2.0.4"
tauri-plugin-shell = "2.2.0" tauri-plugin-shell = "2.2.0"
thiserror = "2.0.3" thiserror = "2.0.3"
ts-rs = "10.0.0" ts-rs = "10.0.0"
yaak-http = { path = "yaak-http" }
yaak-models = { path = "yaak-models" } yaak-models = { path = "yaak-models" }
yaak-plugins = { path = "yaak-plugins" } yaak-plugins = { path = "yaak-plugins" }
yaak-http = { path = "yaak-http" } yaak-sync = { path = "yaak-sync" }
yaak-sse = { path = "yaak-sse" } yaak-sse = { path = "yaak-sse" }
yaak-templates = { path = "yaak-templates" } yaak-templates = { path = "yaak-templates" }

View File

@@ -51,6 +51,7 @@
"opener:allow-reveal-item-in-dir", "opener:allow-reveal-item-in-dir",
"shell:allow-open", "shell:allow-open",
"yaak-license:default", "yaak-license:default",
"yaak-git:default",
"yaak-sync:default", "yaak-sync:default",
"yaak-ws:default" "yaak-ws:default"
] ]

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-dir","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-internal-toggle-maximize","core:window:allow-is-fullscreen","core:window:allow-is-maximized","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-unmaximize","opener:allow-default-urls","opener:allow-open-path","opener:allow-open-url","opener:allow-reveal-item-in-dir","shell:allow-open","yaak-license:default","yaak-sync:default","yaak-ws:default"]}} {"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-dir","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-internal-toggle-maximize","core:window:allow-is-fullscreen","core:window:allow-is-maximized","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-unmaximize","opener:allow-default-urls","opener:allow-open-path","opener:allow-open-url","opener:allow-reveal-item-in-dir","shell:allow-open","yaak-license:default","yaak-git:default","yaak-sync:default","yaak-ws:default"]}}

View File

@@ -5412,6 +5412,131 @@
"type": "string", "type": "string",
"const": "window-state:deny-save-window-state" "const": "window-state:deny-save-window-state"
}, },
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "yaak-git:default"
},
{
"description": "Enables the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-add"
},
{
"description": "Enables the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-branch"
},
{
"description": "Enables the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-checkout"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-commit"
},
{
"description": "Enables the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-delete-branch"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-initialize"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-log"
},
{
"description": "Enables the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-merge-branch"
},
{
"description": "Enables the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-pull"
},
{
"description": "Enables the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-push"
},
{
"description": "Enables the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-status"
},
{
"description": "Enables the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-unstage"
},
{
"description": "Denies the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-add"
},
{
"description": "Denies the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-branch"
},
{
"description": "Denies the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-checkout"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-commit"
},
{
"description": "Denies the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-delete-branch"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-initialize"
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-log"
},
{
"description": "Denies the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-merge-branch"
},
{
"description": "Denies the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-pull"
},
{
"description": "Denies the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-push"
},
{
"description": "Denies the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-status"
},
{
"description": "Denies the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-unstage"
},
{ {
"description": "Default permissions for the plugin", "description": "Default permissions for the plugin",
"type": "string", "type": "string",

View File

@@ -5412,6 +5412,131 @@
"type": "string", "type": "string",
"const": "window-state:deny-save-window-state" "const": "window-state:deny-save-window-state"
}, },
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "yaak-git:default"
},
{
"description": "Enables the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-add"
},
{
"description": "Enables the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-branch"
},
{
"description": "Enables the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-checkout"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-commit"
},
{
"description": "Enables the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-delete-branch"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-initialize"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-log"
},
{
"description": "Enables the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-merge-branch"
},
{
"description": "Enables the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-pull"
},
{
"description": "Enables the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-push"
},
{
"description": "Enables the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-status"
},
{
"description": "Enables the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-unstage"
},
{
"description": "Denies the add command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-add"
},
{
"description": "Denies the branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-branch"
},
{
"description": "Denies the checkout command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-checkout"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-commit"
},
{
"description": "Denies the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-delete-branch"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-initialize"
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-log"
},
{
"description": "Denies the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-merge-branch"
},
{
"description": "Denies the pull command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-pull"
},
{
"description": "Denies the push command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-push"
},
{
"description": "Denies the status command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-status"
},
{
"description": "Denies the unstage command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-unstage"
},
{ {
"description": "Default permissions for the plugin", "description": "Default permissions for the plugin",
"type": "string", "type": "string",

View File

@@ -0,0 +1,2 @@
-- This setting was moved to the new workspace_metas table
ALTER TABLE workspaces DROP COLUMN setting_sync_dir;

View File

@@ -4,6 +4,7 @@ use log::{debug, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use ts_rs::TS; use ts_rs::TS;
use yaak_models::queries::{ use yaak_models::queries::{
generate_id, get_key_value_int, get_key_value_string, get_or_create_settings, generate_id, get_key_value_int, get_key_value_string, get_or_create_settings,

View File

@@ -1819,6 +1819,7 @@ pub fn run() {
.plugin(yaak_license::init()) .plugin(yaak_license::init())
.plugin(yaak_models::plugin::Builder::default().build()) .plugin(yaak_models::plugin::Builder::default().build())
.plugin(yaak_plugins::init()) .plugin(yaak_plugins::init())
.plugin(yaak_git::init())
.plugin(yaak_ws::init()) .plugin(yaak_ws::init())
.plugin(yaak_sync::init()); .plugin(yaak_sync::init());

View File

@@ -0,0 +1,25 @@
[package]
name = "yaak-git"
links = "yaak-git"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
git2 = { version = "0.20.0" }
log = "0.4.22"
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.132"
serde_yaml = "0.9.34"
tauri = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-models = { workspace = true }
yaak-sync = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[build-dependencies]
tauri-plugin = { version = "2.0.3", features = ["build"] }

View File

@@ -0,0 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SyncModel } from "./gen_models";
export type GitAuthor = { name: string | null, email: string | null, };
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
export type GitStatus = "added" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, branches: Array<string>, };
export type PullResult = { receivedBytes: number, receivedObjects: number, };
export type PushResult = "success" | "nothing_to_push";
export type PushType = "branch" | "tag";

View File

@@ -0,0 +1,23 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, environmentId: string | null, createdAt: string, updatedAt: string, name: string, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -0,0 +1,18 @@
const COMMANDS: &[&str] = &[
"add",
"branch",
"checkout",
"commit",
"delete_branch",
"initialize",
"log",
"merge_branch",
"pull",
"push",
"status",
"unstage",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS).build();
}

View File

@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import { GitCommit, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
export * from './bindings/gen_git';
export function useGit(dir: string) {
const queryClient = useQueryClient();
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
return [
{
log: useQuery<void, string, GitCommit[]>({
queryKey: ['git', 'log', dir],
queryFn: () => invoke('plugin:yaak-git|log', { dir }),
}),
status: useQuery<void, string, GitStatusSummary>({
refetchOnMount: true,
queryKey: ['git', 'status', dir],
queryFn: () => invoke('plugin:yaak-git|status', { dir }),
}),
},
{
add: useMutation<void, string, { relaPaths: string[] }>({
mutationKey: ['git', 'add', dir],
mutationFn: (args) => invoke('plugin:yaak-git|add', { dir, ...args }),
onSuccess,
}),
branch: useMutation<void, string, { branch: string }>({
mutationKey: ['git', 'branch', dir],
mutationFn: (args) => invoke('plugin:yaak-git|branch', { dir, ...args }),
onSuccess,
}),
mergeBranch: useMutation<void, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'merge', dir],
mutationFn: (args) => invoke('plugin:yaak-git|merge_branch', { dir, ...args }),
onSuccess,
}),
deleteBranch: useMutation<void, string, { branch: string }>({
mutationKey: ['git', 'delete-branch', dir],
mutationFn: (args) => invoke('plugin:yaak-git|delete_branch', { dir, ...args }),
onSuccess,
}),
checkout: useMutation<void, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'checkout', dir],
mutationFn: (args) => invoke('plugin:yaak-git|checkout', { dir, ...args }),
onSuccess,
}),
commit: useMutation<void, string, { message: string }>({
mutationKey: ['git', 'commit', dir],
mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }),
onSuccess,
}),
push: useMutation<PushResult, string, void>({
mutationKey: ['git', 'push', dir],
mutationFn: () => invoke('plugin:yaak-git|push', { dir }),
onSuccess,
}),
pull: useMutation<PullResult, string, void>({
mutationKey: ['git', 'pull', dir],
mutationFn: () => invoke('plugin:yaak-git|pull', { dir }),
onSuccess,
}),
unstage: useMutation<void, string, { relaPaths: string[] }>({
mutationKey: ['git', 'unstage', dir],
mutationFn: (args) => invoke('plugin:yaak-git|unstage', { dir, ...args }),
onSuccess,
}),
},
] as const;
}
export async function gitInit(dir: string) {
await invoke('plugin:yaak-git|initialize', { dir });
}

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/git",
"private": true,
"version": "1.0.0",
"main": "index.ts"
}

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-add"
description = "Enables the add command without any pre-configured scope."
commands.allow = ["add"]
[[permission]]
identifier = "deny-add"
description = "Denies the add command without any pre-configured scope."
commands.deny = ["add"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-branch"
description = "Enables the branch command without any pre-configured scope."
commands.allow = ["branch"]
[[permission]]
identifier = "deny-branch"
description = "Denies the branch command without any pre-configured scope."
commands.deny = ["branch"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-checkout"
description = "Enables the checkout command without any pre-configured scope."
commands.allow = ["checkout"]
[[permission]]
identifier = "deny-checkout"
description = "Denies the checkout command without any pre-configured scope."
commands.deny = ["checkout"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-commit"
description = "Enables the commit command without any pre-configured scope."
commands.allow = ["commit"]
[[permission]]
identifier = "deny-commit"
description = "Denies the commit command without any pre-configured scope."
commands.deny = ["commit"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-delete-branch"
description = "Enables the delete_branch command without any pre-configured scope."
commands.allow = ["delete_branch"]
[[permission]]
identifier = "deny-delete-branch"
description = "Denies the delete_branch command without any pre-configured scope."
commands.deny = ["delete_branch"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-initialize"
description = "Enables the initialize command without any pre-configured scope."
commands.allow = ["initialize"]
[[permission]]
identifier = "deny-initialize"
description = "Denies the initialize command without any pre-configured scope."
commands.deny = ["initialize"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-log"
description = "Enables the log command without any pre-configured scope."
commands.allow = ["log"]
[[permission]]
identifier = "deny-log"
description = "Denies the log command without any pre-configured scope."
commands.deny = ["log"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-merge-branch"
description = "Enables the merge_branch command without any pre-configured scope."
commands.allow = ["merge_branch"]
[[permission]]
identifier = "deny-merge-branch"
description = "Denies the merge_branch command without any pre-configured scope."
commands.deny = ["merge_branch"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pull"
description = "Enables the pull command without any pre-configured scope."
commands.allow = ["pull"]
[[permission]]
identifier = "deny-pull"
description = "Denies the pull command without any pre-configured scope."
commands.deny = ["pull"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-push"
description = "Enables the push command without any pre-configured scope."
commands.allow = ["push"]
[[permission]]
identifier = "deny-push"
description = "Denies the push command without any pre-configured scope."
commands.deny = ["push"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-status"
description = "Enables the status command without any pre-configured scope."
commands.allow = ["status"]
[[permission]]
identifier = "deny-status"
description = "Denies the status command without any pre-configured scope."
commands.deny = ["status"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-unstage"
description = "Enables the unstage command without any pre-configured scope."
commands.allow = ["unstage"]
[[permission]]
identifier = "deny-unstage"
description = "Denies the unstage command without any pre-configured scope."
commands.deny = ["unstage"]

View File

@@ -0,0 +1,338 @@
## Default Permission
Default permissions for the plugin
- `allow-add`
- `allow-branch`
- `allow-checkout`
- `allow-commit`
- `allow-delete-branch`
- `allow-initialize`
- `allow-log`
- `allow-merge-branch`
- `allow-pull`
- `allow-push`
- `allow-status`
- `allow-unstage`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`yaak-git:allow-add`
</td>
<td>
Enables the add command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-add`
</td>
<td>
Denies the add command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-branch`
</td>
<td>
Enables the branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-branch`
</td>
<td>
Denies the branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-checkout`
</td>
<td>
Enables the checkout command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-checkout`
</td>
<td>
Denies the checkout command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-commit`
</td>
<td>
Enables the commit command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-commit`
</td>
<td>
Denies the commit command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-delete-branch`
</td>
<td>
Enables the delete_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-delete-branch`
</td>
<td>
Denies the delete_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-initialize`
</td>
<td>
Enables the initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-initialize`
</td>
<td>
Denies the initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-log`
</td>
<td>
Enables the log command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-log`
</td>
<td>
Denies the log command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-merge-branch`
</td>
<td>
Enables the merge_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-merge-branch`
</td>
<td>
Denies the merge_branch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-pull`
</td>
<td>
Enables the pull command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-pull`
</td>
<td>
Denies the pull command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-push`
</td>
<td>
Enables the push command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-push`
</td>
<td>
Denies the push command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-status`
</td>
<td>
Enables the status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-status`
</td>
<td>
Denies the status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-unstage`
</td>
<td>
Enables the unstage command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-unstage`
</td>
<td>
Denies the unstage command without any pre-configured scope.
</td>
</tr>
</table>

View File

@@ -0,0 +1,16 @@
[default]
description = "Default permissions for the plugin"
permissions = [
"allow-add",
"allow-branch",
"allow-checkout",
"allow-commit",
"allow-delete-branch",
"allow-initialize",
"allow-log",
"allow-merge-branch",
"allow-pull",
"allow-push",
"allow-status",
"allow-unstage",
]

View File

@@ -0,0 +1,425 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the add command without any pre-configured scope.",
"type": "string",
"const": "allow-add"
},
{
"description": "Denies the add command without any pre-configured scope.",
"type": "string",
"const": "deny-add"
},
{
"description": "Enables the branch command without any pre-configured scope.",
"type": "string",
"const": "allow-branch"
},
{
"description": "Denies the branch command without any pre-configured scope.",
"type": "string",
"const": "deny-branch"
},
{
"description": "Enables the checkout command without any pre-configured scope.",
"type": "string",
"const": "allow-checkout"
},
{
"description": "Denies the checkout command without any pre-configured scope.",
"type": "string",
"const": "deny-checkout"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
"const": "allow-commit"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
"const": "deny-commit"
},
{
"description": "Enables the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "allow-delete-branch"
},
{
"description": "Denies the delete_branch command without any pre-configured scope.",
"type": "string",
"const": "deny-delete-branch"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
"const": "allow-initialize"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",
"const": "deny-initialize"
},
{
"description": "Enables the log command without any pre-configured scope.",
"type": "string",
"const": "allow-log"
},
{
"description": "Denies the log command without any pre-configured scope.",
"type": "string",
"const": "deny-log"
},
{
"description": "Enables the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "allow-merge-branch"
},
{
"description": "Denies the merge_branch command without any pre-configured scope.",
"type": "string",
"const": "deny-merge-branch"
},
{
"description": "Enables the pull command without any pre-configured scope.",
"type": "string",
"const": "allow-pull"
},
{
"description": "Denies the pull command without any pre-configured scope.",
"type": "string",
"const": "deny-pull"
},
{
"description": "Enables the push command without any pre-configured scope.",
"type": "string",
"const": "allow-push"
},
{
"description": "Denies the push command without any pre-configured scope.",
"type": "string",
"const": "deny-push"
},
{
"description": "Enables the status command without any pre-configured scope.",
"type": "string",
"const": "allow-status"
},
{
"description": "Denies the status command without any pre-configured scope.",
"type": "string",
"const": "deny-status"
},
{
"description": "Enables the unstage command without any pre-configured scope.",
"type": "string",
"const": "allow-unstage"
},
{
"description": "Denies the unstage command without any pre-configured scope.",
"type": "string",
"const": "deny-unstage"
},
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "default"
}
]
}
}
}

View File

@@ -0,0 +1,90 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{
bytes_to_string, get_branch_by_name, get_current_branch, get_default_remote_for_push_in_repo,
};
use git2::build::CheckoutBuilder;
use git2::{BranchType, Repository};
use log::info;
use std::path::Path;
pub(crate) fn branch_set_upstream_after_push(repo: &Repository, branch_name: &str) -> Result<()> {
let mut branch = repo.find_branch(branch_name, BranchType::Local)?;
if branch.upstream().is_err() {
let remote = get_default_remote_for_push_in_repo(repo)?;
let upstream_name = format!("{remote}/{branch_name}");
branch.set_upstream(Some(upstream_name.as_str()))?;
}
Ok(())
}
pub(crate) fn git_checkout_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
let repo = open_repo(dir)?;
let branch = get_branch_by_name(&repo, branch)?;
let branch_ref = branch.into_reference();
let branch_tree = branch_ref.peel_to_tree()?;
let mut options = CheckoutBuilder::default();
if force {
options.force();
}
repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?;
repo.set_head(branch_ref.name().unwrap())?;
Ok(())
}
pub(crate) fn git_create_branch(dir: &Path, name: &str) -> Result<()> {
let repo = open_repo(dir)?;
let head = match repo.head() {
Ok(h) => h,
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
let msg = "Cannot create branch when there are no commits";
return Err(GenericError(msg.into()));
}
Err(e) => return Err(e.into()),
};
let head = head.peel_to_commit()?;
repo.branch(name, &head, false)?;
Ok(())
}
pub(crate) fn git_delete_branch(dir: &Path, name: &str) -> Result<()> {
let repo = open_repo(dir)?;
let mut branch = get_branch_by_name(&repo, name)?;
if branch.is_head() {
info!("Deleting head branch");
let branches = repo.branches(Some(BranchType::Local))?;
let other_branch = branches.into_iter().filter_map(|b| b.ok()).find(|b| !b.0.is_head());
let other_branch = match other_branch {
None => return Err(GenericError("Cannot delete only branch".into())),
Some(b) => bytes_to_string(b.0.name_bytes()?)?,
};
git_checkout_branch(dir, &other_branch, true)?;
}
branch.delete()?;
Ok(())
}
pub(crate) fn git_merge_branch(dir: &Path, name: &str, _force: bool) -> Result<()> {
let repo = open_repo(dir)?;
let local_branch = get_current_branch(&repo)?.unwrap();
let commit_to_merge = get_branch_by_name(&repo, name)?.into_reference();
let commit_to_merge = repo.reference_to_annotated_commit(&commit_to_merge)?;
do_merge(&repo, &local_branch, &commit_to_merge)?;
Ok(())
}

View File

@@ -0,0 +1,76 @@
use git2::{Cred, RemoteCallbacks};
use log::{debug, info};
use crate::util::find_ssh_key;
pub(crate) fn default_callbacks<'s>() -> RemoteCallbacks<'s> {
let mut callbacks = RemoteCallbacks::new();
let mut fail_next_call = false;
let mut tried_agent = false;
callbacks.credentials(move |url, username_from_url, allowed_types| {
if fail_next_call {
info!("Failed to get credentials for push");
return Err(git2::Error::from_str("Bad credentials."));
}
debug!("getting credentials {url} {username_from_url:?} {allowed_types:?}");
match (allowed_types.is_ssh_key(), username_from_url) {
(true, Some(username)) => {
if !tried_agent {
tried_agent = true;
return Cred::ssh_key_from_agent(username);
}
fail_next_call = true; // This is our last try
// If the agent failed, try using the default SSH key
if let Some(key) = find_ssh_key() {
Cred::ssh_key(username, None, key.as_path(), None)
} else {
Err(git2::Error::from_str(
"Bad credentials. Ensure your key was added using ssh-add",
))
}
}
(true, None) => Err(git2::Error::from_str("Couldn't get username from url")),
_ => {
todo!("Implement basic auth credential");
}
}
});
callbacks.push_transfer_progress(|current, total, bytes| {
debug!("progress: {}/{} ({} B)", current, total, bytes,);
});
callbacks.transfer_progress(|p| {
debug!("transfer: {}/{}", p.received_objects(), p.total_objects());
true
});
callbacks.pack_progress(|stage, current, total| {
debug!("packing: {:?} - {}/{}", stage, current, total);
});
callbacks.push_update_reference(|reference, msg| {
debug!("push_update_reference: '{}' {:?}", reference, msg);
Ok(())
});
callbacks.update_tips(|name, a, b| {
debug!("update tips: '{}' {} -> {}", name, a, b);
if a != b {
// let mut push_result = push_result.lock().unwrap();
// *push_result = PushResult::Success
}
true
});
callbacks.sideband_progress(|data| {
debug!("sideband transfer: '{}'", String::from_utf8_lossy(data).trim());
true
});
callbacks
}

View File

@@ -0,0 +1,76 @@
use crate::branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
use crate::error::Result;
use crate::git::{
git_add, git_commit, git_init, git_log, git_status, git_unstage, GitCommit, GitStatusSummary,
};
use crate::pull::{git_pull, PullResult};
use crate::push::{git_push, PushResult};
use std::path::{Path, PathBuf};
use tauri::command;
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
#[command]
pub async fn checkout(dir: &Path, branch: &str, force: bool) -> Result<()> {
git_checkout_branch(dir, branch, force)
}
#[command]
pub async fn branch(dir: &Path, branch: &str) -> Result<()> {
git_create_branch(dir, branch)
}
#[command]
pub async fn delete_branch(dir: &Path, branch: &str) -> Result<()> {
git_delete_branch(dir, branch)
}
#[command]
pub async fn merge_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
git_merge_branch(dir, branch, force)
}
#[command]
pub async fn status(dir: &Path) -> Result<GitStatusSummary> {
git_status(dir)
}
#[command]
pub async fn log(dir: &Path) -> Result<Vec<GitCommit>> {
git_log(dir)
}
#[command]
pub async fn initialize(dir: &Path) -> Result<()> {
git_init(dir)
}
#[command]
pub async fn commit(dir: &Path, message: &str) -> Result<()> {
git_commit(dir, message)
}
#[command]
pub async fn push(dir: &Path) -> Result<PushResult> {
git_push(dir)
}
#[command]
pub async fn pull(dir: &Path) -> Result<PullResult> {
git_pull(dir)
}
#[command]
pub async fn add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
for path in rela_paths {
git_add(dir, &path)?;
}
Ok(())
}
#[command]
pub async fn unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
for path in rela_paths {
git_unstage(dir, &path)?;
}
Ok(())
}

View File

@@ -0,0 +1,55 @@
use serde::{Serialize, Serializer};
use std::io;
use std::path::PathBuf;
use std::string::FromUtf8Error;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Git repo not found {0}")]
GitRepoNotFound(PathBuf),
#[error("Git error: {0}")]
GitUnknown(#[from] git2::Error),
#[error("Yaml error: {0}")]
YamlParseError(#[from] serde_yaml::Error),
#[error("Yaml error: {0}")]
ModelError(#[from] yaak_models::error::Error),
#[error("Sync error: {0}")]
SyncError(#[from] yaak_sync::error::Error),
#[error("I/o error: {0}")]
IoError(#[from] io::Error),
#[error("Yaml error: {0}")]
JsonParseError(#[from] serde_json::Error),
#[error("Yaml error: {0}")]
Utf8ConversionError(#[from] FromUtf8Error),
#[error("Git error: {0}")]
GenericError(String),
#[error("No default remote found")]
NoDefaultRemoteFound,
#[error("Merge failed due to conflicts")]
MergeConflicts,
#[error("No active branch")]
NoActiveBranch,
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,670 @@
use crate::error::Result;
use crate::repository::open_repo;
use crate::util::list_branch_names;
use chrono::{DateTime, Utc};
use git2::IndexAddOption;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
use yaak_sync::models::SyncModel;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusSummary {
pub path: String,
pub head_ref: Option<String>,
pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>,
pub origins: Vec<String>,
pub branches: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusEntry {
pub rela_path: String,
pub status: GitStatus,
pub staged: bool,
pub prev: Option<SyncModel>,
pub next: Option<SyncModel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub enum GitStatus {
Added,
Conflict,
Current,
Modified,
Removed,
Renamed,
TypeChange,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitCommit {
author: GitAuthor,
when: DateTime<Utc>,
message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitAuthor {
name: Option<String>,
email: Option<String>,
}
pub fn git_init(dir: &Path) -> Result<()> {
git2::Repository::init(dir)?;
let repo = open_repo(dir)?;
// Default to main instead of master, to align with
// the official Git and GitHub behavior
repo.set_head("refs/heads/main")?;
info!("Initialized {dir:?}");
Ok(())
}
pub fn git_add(dir: &Path, rela_path: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let mut index = repo.index()?;
info!("Staging file {rela_path:?} to {dir:?}");
index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?;
index.write()?;
Ok(())
}
pub fn git_unstage(dir: &Path, rela_path: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let head = match repo.head() {
Ok(h) => h,
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
info!("Unstaging file in empty branch {rela_path:?} to {dir:?}");
// Repo has no commits, so "unstage" means remove from index
let mut index = repo.index()?;
index.remove_path(rela_path)?;
index.write()?;
return Ok(());
}
Err(e) => return Err(e.into()),
};
// If repo has commits, update the index entry back to HEAD
info!("Unstaging file {rela_path:?} to {dir:?}");
let commit = head.peel_to_commit()?;
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
Ok(())
}
pub fn git_commit(dir: &Path, message: &str) -> Result<()> {
let repo = open_repo(dir)?;
// Clear the in-memory index, add the paths, and write the tree for committing
let tree_oid = repo.index()?.write_tree()?;
let tree = repo.find_tree(tree_oid)?;
// Make the signature
let config = git2::Config::open_default()?.snapshot()?;
let name = config.get_str("user.name").unwrap_or("Change Me");
let email = config.get_str("user.email").unwrap_or("change_me@example.com");
let sig = git2::Signature::now(name, email)?;
// Get the current HEAD commit (if it exists)
let parent_commit = match repo.head() {
Ok(head) => Some(head.peel_to_commit()?),
Err(_) => None, // No parent if no HEAD exists (initial commit)
};
let parents = parent_commit.as_ref().map(|p| vec![p]).unwrap_or_default();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, parents.as_slice())?;
info!("Committed to {dir:?}");
Ok(())
}
pub fn git_log(dir: &Path) -> Result<Vec<GitCommit>> {
let repo = open_repo(dir)?;
// Return empty if empty repo or no head (new repo)
if repo.is_empty()? || repo.head().is_err() {
return Ok(vec![]);
}
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
// Run git log
macro_rules! filter_try {
($e:expr) => {
match $e {
Ok(t) => t,
Err(_) => return None,
}
};
}
let log: Vec<GitCommit> = revwalk
.filter_map(|oid| {
let oid = filter_try!(oid);
let commit = filter_try!(repo.find_commit(oid));
let author = commit.author();
Some(GitCommit {
author: GitAuthor {
name: author.name().map(|s| s.to_string()),
email: author.email().map(|s| s.to_string()),
},
when: convert_git_time_to_date(author.when()),
message: commit.message().map(|m| m.to_string()),
})
})
.collect();
Ok(log)
}
pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
let repo = open_repo(dir)?;
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
Ok(head) => {
let tree = head.peel_to_tree().ok();
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
let head_ref = head.name().map(|s| s.to_string());
(tree, head_ref, head_ref_shorthand)
}
Err(_) => {
// For "unborn" repos, reading from HEAD is the only way to get the branch name
// See https://github.com/starship/starship/pull/1336
let head_path = repo.path().join("HEAD");
let head_ref = fs::read_to_string(&head_path)
.ok()
.unwrap_or_default()
.lines()
.next()
.map(|s| s.trim_start_matches("ref:").trim().to_string());
let head_ref_shorthand =
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
(None, head_ref, head_ref_shorthand)
}
};
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
.include_untracked(true) // Include untracked
.recurse_untracked_dirs(true) // Show all untracked
.include_unmodified(true); // Include unchanged
// TODO: Support renames
let mut entries: Vec<GitStatusEntry> = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
let rela_path = entry.path().unwrap().to_string();
let status = entry.status();
let index_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Added,
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown index status {s:?}");
continue;
}
};
let worktree_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::WT_NEW) => GitStatus::Added,
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown worktree status {s:?}");
continue;
}
};
let status = if index_status == GitStatus::Current {
worktree_status.clone()
} else {
index_status.clone()
};
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
{
// No change, so can't be added
false
} else if index_status != GitStatus::Current {
true
} else {
false
};
// Get previous content from Git, if it's in there
let prev = match head_tree.clone() {
None => None,
Some(t) => match t.get_path(&Path::new(&rela_path)) {
Ok(entry) => {
let obj = entry.to_object(&repo).unwrap();
let content = obj.as_blob().unwrap().content();
let name = Path::new(entry.name().unwrap_or_default());
SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)
}
Err(_) => None,
},
};
let next = {
let full_path = repo.workdir().unwrap().join(rela_path.clone());
SyncModel::from_file(full_path.as_path())?.map(|m| m.0)
};
entries.push(GitStatusEntry {
status,
staged,
rela_path,
prev: prev.clone(),
next: next.clone(),
})
}
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
let branches = list_branch_names(&repo)?;
Ok(GitStatusSummary {
entries,
origins,
path: dir.to_string_lossy().to_string(),
head_ref,
head_ref_shorthand,
branches,
})
}
#[cfg(test)]
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).unwrap()
}
#[cfg(not(test))]
fn convert_git_time_to_date(git_time: git2::Time) -> DateTime<Utc> {
let timestamp = git_time.seconds();
DateTime::from_timestamp(timestamp, 0).unwrap()
}
// // Write a test
// #[cfg(test)]
// mod test {
// use crate::error::Error::GitRepoNotFound;
// use crate::error::Result;
// use crate::git::{
// git_add, git_commit, git_init, git_log, git_status, git_unstage, open_repo, GitStatus,
// GitStatusEntry,
// };
// use std::fs::{create_dir_all, remove_file, File};
// use std::io::Write;
// use std::path::{Path, PathBuf};
// use tempdir::TempDir;
//
// fn new_dir() -> PathBuf {
// let p = TempDir::new("yaak-git").unwrap().into_path();
// p
// }
//
// fn new_file(path: &Path, content: &str) {
// let parent = path.parent().unwrap();
// create_dir_all(parent).unwrap();
// File::create(path).unwrap().write_all(content.as_bytes()).unwrap();
// }
//
// #[tokio::test]
// async fn test_status_no_repo() {
// let dir = &new_dir();
// let result = git_status(dir).await;
// assert!(matches!(result, Err(GitRepoNotFound(_))));
// }
//
// #[test]
// fn test_open_repo() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
// open_repo(dir.as_path())?;
// Ok(())
// }
//
// #[test]
// fn test_open_repo_from_subdir() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let sub_dir = dir.join("a").join("b");
// create_dir_all(sub_dir.as_path())?; // Create sub dir
//
// open_repo(sub_dir.as_path())?;
// Ok(())
// }
//
// #[tokio::test]
// async fn test_status() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// assert_eq!(git_status(dir).await?.entries, Vec::new());
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
// new_file(&dir.join("dir/baz.txt"), "baz");
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "dir/baz.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("baz".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_add() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo".to_string()),
// },
// ],
// );
//
// new_file(&dir.join("foo.txt"), "foo foo");
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo foo".to_string()),
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_unstage() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo".to_string()),
// },
// ]
// );
//
// git_unstage(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// }
// ]
// );
//
// Ok(())
// }
//
// #[tokio::test]
// fn test_commit() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// },
// ]
// );
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "This is my message")?;
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Current,
// staged: false,
// prev: Some("foo".to_string()),
// next: Some("foo".to_string()),
// },
// ]
// );
//
// new_file(&dir.join("foo.txt"), "foo foo");
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Modified,
// staged: true,
// prev: Some("foo".to_string()),
// next: Some("foo foo".to_string()),
// },
// ]
// );
// Ok(())
// }
//
// #[tokio::test]
// async fn test_add_removed_file() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let foo_path = &dir.join("foo.txt");
// let bar_path = &dir.join("bar.txt");
//
// new_file(foo_path, "foo");
// new_file(bar_path, "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "Initial commit")?;
//
// remove_file(foo_path)?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Removed,
// staged: false,
// prev: Some("foo".to_string()),
// next: None,
// },
// ],
// );
//
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Removed,
// staged: true,
// prev: Some("foo".to_string()),
// next: None,
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_log_empty() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let log = git_log(dir)?;
// assert_eq!(log.len(), 0);
// Ok(())
// }
//
// #[test]
// fn test_log() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "This is my message")?;
//
// let log = git_log(dir)?;
// assert_eq!(log.len(), 1);
// assert_eq!(log.get(0).unwrap().message, Some("This is my message".to_string()));
// Ok(())
// }
// }

View File

@@ -0,0 +1,36 @@
use crate::commands::{add, branch, checkout, commit, delete_branch, initialize, log, merge_branch, pull, push, status, unstage};
use tauri::{
generate_handler,
plugin::{Builder, TauriPlugin},
Runtime,
};
mod branch;
mod callbacks;
mod commands;
mod error;
mod git;
mod merge;
mod pull;
mod push;
mod repository;
mod util;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-git")
.invoke_handler(generate_handler![
add,
branch,
checkout,
commit,
delete_branch,
initialize,
log,
merge_branch,
pull,
push,
status,
unstage
])
.build()
}

View File

@@ -0,0 +1,135 @@
use crate::error::Error::MergeConflicts;
use crate::util::bytes_to_string;
use git2::{AnnotatedCommit, Branch, IndexEntry, Reference, Repository};
use log::{debug, info};
pub(crate) fn do_merge(
repo: &Repository,
local_branch: &Branch,
commit_to_merge: &AnnotatedCommit,
) -> crate::error::Result<()> {
debug!("Merging remote branches");
let analysis = repo.merge_analysis(&[&commit_to_merge])?;
if analysis.0.is_fast_forward() {
let refname = bytes_to_string(local_branch.get().name_bytes())?;
match repo.find_reference(&refname) {
Ok(mut r) => {
merge_fast_forward(repo, &mut r, &commit_to_merge)?;
}
Err(_) => {
// The branch doesn't exist, so set the reference to the commit directly. Usually
// this is because you are pulling into an empty repository.
repo.reference(
&refname,
commit_to_merge.id(),
true,
&format!("Setting {} to {}", refname, commit_to_merge.id()),
)?;
repo.set_head(&refname)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
.allow_conflicts(true)
.conflict_style_merge(true)
.force(),
))?;
}
};
} else if analysis.0.is_normal() {
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
merge_normal(repo, &head_commit, commit_to_merge)?;
} else {
debug!("Skipping merge. Nothing to do")
}
Ok(())
}
pub(crate) fn merge_fast_forward(
repo: &Repository,
local_reference: &mut Reference,
remote_commit: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing fast forward");
let name = match local_reference.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(local_reference.name_bytes()).to_string(),
};
let msg = format!("Fast-Forward: Setting {} to id: {}", name, remote_commit.id());
local_reference.set_target(remote_commit.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
// For some reason, the force is required to make the working directory actually get
// updated I suspect we should be adding some logic to handle dirty working directory
// states, but this is just an example so maybe not.
.force(),
))?;
Ok(())
}
pub(crate) fn merge_normal(
repo: &Repository,
local: &AnnotatedCommit,
remote: &AnnotatedCommit,
) -> crate::error::Result<()> {
info!("Performing normal merge");
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let ancestor = repo.find_commit(repo.merge_base(local.id(), remote.id())?)?.tree()?;
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
if idx.has_conflicts() {
let conflicts = idx.conflicts()?;
for conflict in conflicts {
if let Ok(conflict) = conflict {
print_conflict(&conflict);
}
}
return Err(MergeConflicts);
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
// now create the merge commit
let msg = format!("Merge: {} into {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
// Do our merge commit and set current branch head to that commit.
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
&sig,
&msg,
&result_tree,
&[&local_commit, &remote_commit],
)?;
// Set working tree to match head.
repo.checkout_head(None)?;
Ok(())
}
fn print_conflict(conflict: &git2::IndexConflict) {
let ancestor = conflict.ancestor.as_ref().map(path_from_index_entry);
let ours = conflict.our.as_ref().map(path_from_index_entry);
let theirs = conflict.their.as_ref().map(path_from_index_entry);
println!("Conflict detected:");
if let Some(path) = ancestor {
println!(" Common ancestor: {:?}", path);
}
if let Some(path) = ours {
println!(" Ours: {:?}", path);
}
if let Some(path) = theirs {
println!(" Theirs: {:?}", path);
}
}
fn path_from_index_entry(entry: &IndexEntry) -> String {
String::from_utf8_lossy(entry.path.as_slice()).into_owned()
}

View File

@@ -0,0 +1,54 @@
use crate::callbacks::default_callbacks;
use crate::error::Error::NoActiveBranch;
use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{bytes_to_string, get_current_branch};
use git2::{FetchOptions, ProxyOptions};
use log::debug;
use serde::{Deserialize, Serialize};
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) struct PullResult {
received_bytes: usize,
received_objects: usize,
}
pub(crate) fn git_pull(dir: &Path) -> Result<PullResult> {
let repo = open_repo(dir)?;
let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;
let branch_ref = branch.get();
let branch_ref = bytes_to_string(branch_ref.name_bytes())?;
let remote_name = repo.branch_upstream_remote(&branch_ref)?;
let remote_name = bytes_to_string(&remote_name)?;
debug!("Pulling from {remote_name}");
let mut remote = repo.find_remote(&remote_name)?;
let mut options = FetchOptions::new();
let callbacks = default_callbacks();
options.remote_callbacks(callbacks);
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
remote.fetch(&[&branch_ref], Some(&mut options), None)?;
let stats = remote.stats();
let fetch_head = repo.find_reference("FETCH_HEAD")?;
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
do_merge(&repo, &branch, &fetch_commit)?;
Ok(PullResult {
received_bytes: stats.received_bytes(),
received_objects: stats.received_objects(),
})
}

View File

@@ -0,0 +1,74 @@
use crate::branch::branch_set_upstream_after_push;
use crate::callbacks::default_callbacks;
use crate::error::Result;
use crate::repository::open_repo;
use git2::{ProxyOptions, PushOptions};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Mutex;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) enum PushType {
Branch,
Tag,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) enum PushResult {
Success,
NothingToPush,
}
pub(crate) fn git_push(dir: &Path) -> Result<PushResult> {
let repo = open_repo(dir)?;
let head = repo.head()?;
let branch = head.shorthand().unwrap();
let mut remote = repo.find_remote("origin")?;
let mut options = PushOptions::new();
options.packbuilder_parallelism(0);
let push_result = Mutex::new(PushResult::NothingToPush);
let mut callbacks = default_callbacks();
callbacks.push_transfer_progress(|_current, _total, _bytes| {
let mut push_result = push_result.lock().unwrap();
*push_result = PushResult::Success;
});
options.remote_callbacks(default_callbacks());
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
// Push the current branch
let force = false;
let delete = false;
let branch_modifier = match (force, delete) {
(true, true) => "+:",
(false, true) => ":",
(true, false) => "+",
(false, false) => "",
};
let ref_type = PushType::Branch;
let ref_type = match ref_type {
PushType::Branch => "heads",
PushType::Tag => "tags",
};
let refspec = format!("{branch_modifier}refs/{ref_type}/{branch}");
remote.push(&[refspec], Some(&mut options))?;
branch_set_upstream_after_push(&repo, branch)?;
let push_result = push_result.lock().unwrap();
Ok(push_result.clone())
}

View File

@@ -0,0 +1,11 @@
use std::path::Path;
use crate::error::Error::{GitRepoNotFound, GitUnknown};
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
match git2::Repository::discover(dir) {
Ok(r) => Ok(r),
Err(e) if e.code() == git2::ErrorCode::NotFound => Err(GitRepoNotFound(dir.to_path_buf())),
Err(e) => Err(GitUnknown(e)),
}
}

View File

@@ -0,0 +1,107 @@
use crate::error::Error::{GenericError, NoDefaultRemoteFound};
use crate::error::Result;
use git2::{Branch, BranchType, Repository};
use std::env;
use std::path::{Path, PathBuf};
const DEFAULT_REMOTE_NAME: &str = "origin";
pub(crate) fn find_ssh_key() -> Option<PathBuf> {
let home_dir = env::var("HOME").ok()?;
let key_paths = [
format!("{}/.ssh/id_ed25519", home_dir),
format!("{}/.ssh/id_rsa", home_dir),
format!("{}/.ssh/id_ecdsa", home_dir),
format!("{}/.ssh/id_dsa", home_dir),
];
for key_path in key_paths.iter() {
let path = Path::new(key_path);
if path.exists() {
return Some(path.to_path_buf());
}
}
None
}
pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch>> {
for b in repo.branches(None)? {
let branch = b?.0;
if branch.is_head() {
return Ok(Some(branch));
}
}
Ok(None)
}
pub(crate) fn list_branch_names(repo: &Repository) -> Result<Vec<String>> {
let mut branches = Vec::new();
for branch in repo.branches(Some(BranchType::Local))? {
let branch = branch?.0;
let name = branch.name_bytes()?;
let name = bytes_to_string(name)?;
branches.push(name);
}
Ok(branches)
}
pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result<Branch<'s>> {
Ok(repo.find_branch(name, BranchType::Local)?)
}
pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
Ok(String::from_utf8(bytes.to_vec())?)
}
pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result<String> {
let config = repo.config()?;
let branch = get_current_branch(repo)?;
if let Some(branch) = branch {
let remote_name = bytes_to_string(branch.name_bytes()?)?;
let entry_name = format!("branch.{}.pushRemote", &remote_name);
if let Ok(entry) = config.get_entry(&entry_name) {
return bytes_to_string(entry.value_bytes());
}
if let Ok(entry) = config.get_entry("remote.pushDefault") {
return bytes_to_string(entry.value_bytes());
}
let entry_name = format!("branch.{}.remote", &remote_name);
if let Ok(entry) = config.get_entry(&entry_name) {
return bytes_to_string(entry.value_bytes());
}
}
get_default_remote_in_repo(repo)
}
pub(crate) fn get_default_remote_in_repo(repo: &Repository) -> Result<String> {
let remotes = repo.remotes()?;
// if `origin` exists return that
let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));
if found_origin {
return Ok(DEFAULT_REMOTE_NAME.into());
}
// if only one remote exists pick that
if remotes.len() == 1 {
let first_remote = remotes
.iter()
.next()
.flatten()
.map(String::from)
.ok_or_else(|| GenericError("no remote found".into()))?;
return Ok(first_remote);
}
// inconclusive
Err(NoDefaultRemoteFound)
}

View File

@@ -147,7 +147,7 @@ impl<'s> TryFrom<&Row<'s>> for Settings {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct Workspace { pub struct Workspace {
@@ -325,7 +325,7 @@ impl<'s> TryFrom<&Row<'s>> for CookieJar {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct Environment { pub struct Environment {
@@ -374,7 +374,7 @@ impl<'s> TryFrom<&Row<'s>> for Environment {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct EnvironmentVariable { pub struct EnvironmentVariable {
@@ -387,7 +387,7 @@ pub struct EnvironmentVariable {
pub id: Option<String>, pub id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct Folder { pub struct Folder {
@@ -438,7 +438,7 @@ impl<'s> TryFrom<&Row<'s>> for Folder {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequestHeader { pub struct HttpRequestHeader {
@@ -451,7 +451,7 @@ pub struct HttpRequestHeader {
pub id: Option<String>, pub id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct HttpUrlParameter { pub struct HttpUrlParameter {
@@ -464,7 +464,7 @@ pub struct HttpUrlParameter {
pub id: Option<String>, pub id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequest { pub struct HttpRequest {
@@ -637,7 +637,7 @@ impl Default for WebsocketMessageType {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct WebsocketRequest { pub struct WebsocketRequest {
@@ -895,7 +895,7 @@ impl HttpResponse {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct GrpcMetadataEntry { pub struct GrpcMetadataEntry {
@@ -908,7 +908,7 @@ pub struct GrpcMetadataEntry {
pub id: Option<String>, pub id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct GrpcRequest { pub struct GrpcRequest {

View File

@@ -2569,20 +2569,22 @@ pub async fn batch_upsert<R: Runtime>(
let mut imported_resources = BatchUpsertResult::default(); let mut imported_resources = BatchUpsertResult::default();
if workspaces.len() > 0 { if workspaces.len() > 0 {
info!("Batch inserting {} workspaces", workspaces.len());
for v in workspaces { for v in workspaces {
let x = upsert_workspace(&window, v, update_source).await?; let x = upsert_workspace(&window, v, update_source).await?;
imported_resources.workspaces.push(x.clone()); imported_resources.workspaces.push(x.clone());
} }
info!("Imported {} workspaces", imported_resources.workspaces.len());
} }
if environments.len() > 0 { if environments.len() > 0 {
while imported_resources.environments.len() < environments.len() { while imported_resources.environments.len() < environments.len() {
for v in environments.clone() { for v in environments.clone() {
if let Some(fid) = v.environment_id.clone() { if let Some(id) = v.environment_id.clone() {
let has_parent_to_import = environments.iter().find(|m| m.id == id).is_some();
let imported_parent = let imported_parent =
imported_resources.environments.iter().find(|f| f.id == fid); imported_resources.environments.iter().find(|m| m.id == id);
if imported_parent.is_none() { // If there's also a parent to upsert, wait for that one
if imported_parent.is_none() && has_parent_to_import {
continue; continue;
} }
} }
@@ -2599,9 +2601,11 @@ pub async fn batch_upsert<R: Runtime>(
if folders.len() > 0 { if folders.len() > 0 {
while imported_resources.folders.len() < folders.len() { while imported_resources.folders.len() < folders.len() {
for v in folders.clone() { for v in folders.clone() {
if let Some(fid) = v.folder_id.clone() { if let Some(id) = v.folder_id.clone() {
let imported_parent = imported_resources.folders.iter().find(|f| f.id == fid); let has_parent_to_import = folders.iter().find(|m| m.id == id).is_some();
if imported_parent.is_none() { let imported_parent = imported_resources.folders.iter().find(|m| m.id == id);
// If there's also a parent to upsert, wait for that one
if imported_parent.is_none() && has_parent_to_import {
continue; continue;
} }
} }

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GitCommit = { author: string, when: string, message: string | null, };
export type GitStatus = "added" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: string | null, next: string | null, };

View File

@@ -3,6 +3,8 @@ import { emit } from '@tauri-apps/api/event';
import { SyncOp } from './bindings/gen_sync'; import { SyncOp } from './bindings/gen_sync';
import { WatchEvent, WatchResult } from './bindings/gen_watch'; import { WatchEvent, WatchResult } from './bindings/gen_watch';
export * from './bindings/gen_models';
export async function calculateSync(workspaceId: string, syncDir: string) { export async function calculateSync(workspaceId: string, syncDir: string) {
return invoke<SyncOp[]>('plugin:yaak-sync|calculate', { return invoke<SyncOp[]>('plugin:yaak-sync|calculate', {
workspaceId, workspaceId,
@@ -27,20 +29,53 @@ export function watchWorkspaceFiles(
syncDir: string, syncDir: string,
callback: (e: WatchEvent) => void, callback: (e: WatchEvent) => void,
) { ) {
console.log('Watching workspace files', workspaceId, syncDir);
const channel = new Channel<WatchEvent>(); const channel = new Channel<WatchEvent>();
channel.onmessage = callback; channel.onmessage = callback;
const promise = invoke<WatchResult>('plugin:yaak-sync|watch', { const unlistenPromise = invoke<WatchResult>('plugin:yaak-sync|watch', {
workspaceId, workspaceId,
syncDir, syncDir,
channel, channel,
}); });
return () => { unlistenPromise.then(({ unlistenEvent }) => {
promise addWatchKey(unlistenEvent);
.then(({ unlistenEvent }) => { });
console.log('Cancelling workspace watch', workspaceId, unlistenEvent);
return emit(unlistenEvent); return () =>
unlistenPromise
.then(async ({ unlistenEvent }) => {
console.log('Unwatching workspace files', workspaceId, syncDir);
unlistenToWatcher(unlistenEvent);
}) })
.catch(console.error); .catch(console.error);
};
} }
function unlistenToWatcher(unlistenEvent: string) {
emit(unlistenEvent).then(() => {
removeWatchKey(unlistenEvent);
});
}
function getWatchKeys() {
return sessionStorage.getItem('workspace-file-watchers')?.split(',').filter(Boolean) ?? [];
}
function setWatchKeys(keys: string[]) {
sessionStorage.setItem('workspace-file-watchers', keys.join(','));
}
function addWatchKey(key: string) {
const keys = getWatchKeys();
setWatchKeys([...keys, key]);
}
function removeWatchKey(key: string) {
const keys = getWatchKeys();
setWatchKeys(keys.filter((k) => k !== key));
}
// On page load, unlisten to all zombie watchers
const keys = getWatchKeys();
console.log('Unsubscribing to zombie file watchers', keys);
keys.forEach(unlistenToWatcher);

View File

@@ -6,8 +6,8 @@ use tauri::{
}; };
mod commands; mod commands;
mod error; pub mod error;
mod models; pub mod models;
mod sync; mod sync;
mod watch; mod watch;

View File

@@ -3,14 +3,14 @@ use crate::error::Result;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1}; use sha1::{Digest, Sha1};
use std::fs;
use std::path::Path; use std::path::Path;
use tokio::fs;
use ts_rs::TS; use ts_rs::TS;
use yaak_models::models::{ use yaak_models::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace, AnyModel, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
}; };
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")] #[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub enum SyncModel { pub enum SyncModel {
@@ -23,12 +23,10 @@ pub enum SyncModel {
} }
impl SyncModel { impl SyncModel {
pub async fn from_file(file_path: &Path) -> Result<Option<(SyncModel, Vec<u8>, String)>> { pub fn from_bytes(
let content = match fs::read(file_path).await { content: Vec<u8>,
Ok(c) => c, file_path: &Path,
Err(_) => return Ok(None), ) -> Result<Option<(SyncModel, Vec<u8>, String)>> {
};
let mut hasher = Sha1::new(); let mut hasher = Sha1::new();
hasher.update(&content); hasher.update(&content);
let checksum = hex::encode(hasher.finalize()); let checksum = hex::encode(hasher.finalize());
@@ -39,10 +37,20 @@ impl SyncModel {
} else if ext == "json" { } else if ext == "json" {
Ok(Some((serde_json::from_reader(content.as_slice())?, content, checksum))) Ok(Some((serde_json::from_reader(content.as_slice())?, content, checksum)))
} else { } else {
Err(InvalidSyncFile(file_path.to_str().unwrap().to_string())) let p = file_path.to_str().unwrap().to_string();
Err(InvalidSyncFile(format!("Unknown file extension {p}")))
} }
} }
pub fn from_file(file_path: &Path) -> Result<Option<(SyncModel, Vec<u8>, String)>> {
let content = match fs::read(file_path) {
Ok(c) => c,
Err(_) => return Ok(None),
};
Self::from_bytes(content, file_path)
}
pub fn to_file_contents(&self, rel_path: &Path) -> Result<(Vec<u8>, String)> { pub fn to_file_contents(&self, rel_path: &Path) -> Result<(Vec<u8>, String)> {
let ext = rel_path.extension().unwrap_or_default(); let ext = rel_path.extension().unwrap_or_default();
let content = if ext == "yaml" || ext == "yml" { let content = if ext == "yaml" || ext == "yml" {

View File

@@ -1,4 +1,3 @@
use crate::error::Error::InvalidSyncFile;
use crate::error::Result; use crate::error::Result;
use crate::models::SyncModel; use crate::models::SyncModel;
use chrono::Utc; use chrono::Utc;
@@ -164,13 +163,12 @@ pub(crate) async fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
}; };
let path = dir_entry.path(); let path = dir_entry.path();
let (model, _, checksum) = match SyncModel::from_file(&path).await { let (model, _, checksum) = match SyncModel::from_file(&path) {
Ok(Some(m)) => m, Ok(Some(m)) => m,
Ok(None) => continue, Ok(None) => continue,
Err(InvalidSyncFile(_)) => continue,
Err(e) => { Err(e) => {
warn!("Failed to read sync file {e}"); warn!("Failed to read sync file {e}");
continue; return Err(e);
} }
}; };
@@ -315,7 +313,7 @@ pub(crate) async fn apply_sync_ops<R: Runtime>(
} }
debug!( debug!(
"Sync ops {}", "Applying sync ops {}",
sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ") sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(", ")
); );
let mut sync_state_ops = Vec::new(); let mut sync_state_ops = Vec::new();

View File

@@ -45,9 +45,19 @@ pub(crate) async fn watch_directory(
Some(event_res) = async_rx.recv() => { Some(event_res) = async_rx.recv() => {
match event_res { match event_res {
Ok(event) => { Ok(event) => {
// Filter out any ignored directories and see if we still get a result
let paths = event.paths.into_iter()
.map(|p| p.strip_prefix(&dir).unwrap().to_path_buf())
.filter(|p| !p.starts_with(".git") && !p.starts_with("node_modules"))
.collect::<Vec<PathBuf>>();
if paths.is_empty() {
continue;
}
channel channel
.send(WatchEvent { .send(WatchEvent {
paths: event.paths, paths,
kind: format!("{:?}", event.kind), kind: format!("{:?}", event.kind),
}) })
.expect("Failed to send watch event"); .expect("Failed to send watch event");

View File

@@ -1,10 +1,10 @@
mod cmd; mod commands;
mod connect; mod connect;
mod error; mod error;
mod manager; mod manager;
mod render; mod render;
use crate::cmd::{ use crate::commands::{
connect, close, delete_connection, delete_connections, delete_request, duplicate_request, connect, close, delete_connection, delete_connections, delete_request, duplicate_request,
list_connections, list_events, list_requests, send, upsert_request, list_connections, list_events, list_requests, send, upsert_request,
}; };

View File

@@ -48,10 +48,10 @@ export const createFolder = createFastMutation<
export const syncWorkspace = createFastMutation< export const syncWorkspace = createFastMutation<
void, void,
void, void,
{ workspaceId: string; syncDir: string } { workspaceId: string; syncDir: string; force?: boolean }
>({ >({
mutationKey: [], mutationKey: [],
mutationFn: async ({ workspaceId, syncDir }) => { mutationFn: async ({ workspaceId, syncDir, force }) => {
const ops = (await calculateSync(workspaceId, syncDir)) ?? []; const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
if (ops.length === 0) { if (ops.length === 0) {
console.log('Nothing to sync', workspaceId, syncDir); console.log('Nothing to sync', workspaceId, syncDir);
@@ -72,66 +72,68 @@ export const syncWorkspace = createFastMutation<
console.log('Filesystem changes detected', { dbOps, ops }); console.log('Filesystem changes detected', { dbOps, ops });
const confirmed = await showConfirm({ const confirmed = force
id: 'commit-sync', ? true
title: 'Filesystem Changes Detected', : await showConfirm({
confirmText: 'Apply Changes', id: 'commit-sync',
description: ( title: 'Filesystem Changes Detected',
<VStack space={3}> confirmText: 'Apply Changes',
{isDeletingWorkspace && ( description: (
<Banner color="danger"> <VStack space={3}>
🚨 <strong>Changes contain a workspace deletion!</strong> {isDeletingWorkspace && (
</Banner> <Banner color="danger">
)} 🚨 <strong>Changes contain a workspace deletion!</strong>
<p> </Banner>
{pluralizeCount('file', dbOps.length)} in the directory have changed. Do you want to )}
apply the updates to your workspace? <p>
</p> {pluralizeCount('file', dbOps.length)} in the directory have changed. Do you want to
<div className="overflow-y-auto max-h-[10rem]"> apply the updates to your workspace?
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> </p>
<thead> <div className="overflow-y-auto max-h-[10rem]">
<tr> <table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<th className="py-1 text-left">Name</th> <thead>
<th className="py-1 text-right pl-4">Operation</th> <tr>
</tr> <th className="py-1 text-left">Name</th>
</thead> <th className="py-1 text-right pl-4">Operation</th>
<tbody className="divide-y divide-surface-highlight">
{dbOps.map((op, i) => {
let name = '';
let label = '';
let color = '';
if (op.type === 'dbCreate') {
label = 'create';
name = fallbackRequestName(op.fs.model);
color = 'text-success';
} else if (op.type === 'dbUpdate') {
label = 'update';
name = fallbackRequestName(op.fs.model);
color = 'text-info';
} else if (op.type === 'dbDelete') {
label = 'delete';
name = fallbackRequestName(op.model);
color = 'text-danger';
} else {
return null;
}
return (
<tr key={i} className="text-text">
<td className="py-1">{name}</td>
<td className="py-1 pl-4 text-right">
<InlineCode className={color}>{label}</InlineCode>
</td>
</tr> </tr>
); </thead>
})} <tbody className="divide-y divide-surface-highlight">
</tbody> {dbOps.map((op, i) => {
</table> let name = '';
</div> let label = '';
</VStack> let color = '';
),
}); if (op.type === 'dbCreate') {
label = 'create';
name = fallbackRequestName(op.fs.model);
color = 'text-success';
} else if (op.type === 'dbUpdate') {
label = 'update';
name = fallbackRequestName(op.fs.model);
color = 'text-info';
} else if (op.type === 'dbDelete') {
label = 'delete';
name = fallbackRequestName(op.model);
color = 'text-danger';
} else {
return null;
}
return (
<tr key={i} className="text-text">
<td className="py-1">{name}</td>
<td className="py-1 pl-4 text-right">
<InlineCode className={color}>{label}</InlineCode>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</VStack>
),
});
if (confirmed) { if (confirmed) {
await applySync(workspaceId, syncDir, ops); await applySync(workspaceId, syncDir, ops);
} }

View File

@@ -1,18 +1,17 @@
import type {WebsocketRequest} from "@yaakapp-internal/models"; import type { WebsocketRequest } from '@yaakapp-internal/models';
import { deleteWebsocketRequest as cmdDeleteWebsocketRequest } from '@yaakapp-internal/ws'; import { deleteWebsocketRequest as cmdDeleteWebsocketRequest } from '@yaakapp-internal/ws';
import { InlineCode } from '../components/core/InlineCode'; import { InlineCode } from '../components/core/InlineCode';
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import { showConfirm } from '../lib/confirm'; import { showConfirmDelete } from '../lib/confirm';
import { fallbackRequestName } from '../lib/fallbackRequestName'; import { fallbackRequestName } from '../lib/fallbackRequestName';
export const deleteWebsocketRequest = createFastMutation({ export const deleteWebsocketRequest = createFastMutation({
mutationKey: ['delete_websocket_request'], mutationKey: ['delete_websocket_request'],
mutationFn: async (request: WebsocketRequest) => { mutationFn: async (request: WebsocketRequest) => {
const confirmed = await showConfirm({ const confirmed = await showConfirmDelete({
id: 'delete-websocket-request', id: 'delete-websocket-request',
title: 'Delete WebSocket Request', title: 'Delete WebSocket Request',
variant: 'delete',
description: ( description: (
<> <>
Permanently delete <InlineCode>{fallbackRequestName(request)}</InlineCode>? Permanently delete <InlineCode>{fallbackRequestName(request)}</InlineCode>?

View File

@@ -0,0 +1,27 @@
import { SettingsTab } from '../components/Settings/SettingsTab';
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
export const openSettings = createFastMutation<void, string, SettingsTab | null>({
mutationKey: ['open_settings'],
mutationFn: async function (tab) {
const workspaceId = getActiveWorkspaceId();
if (workspaceId == null) return;
trackEvent('dialog', 'show', { id: 'settings', tab: `${tab}` });
const location = router.buildLocation({
to: '/workspaces/$workspaceId/settings',
params: { workspaceId },
search: { tab: tab ?? SettingsTab.General },
});
await invokeCmd('cmd_new_child_window', {
url: location.href,
label: 'settings',
title: 'Yaak Settings',
innerSize: [750, 600],
});
},
});

View File

@@ -0,0 +1,25 @@
import { WorkspaceSettingsDialog } from '../components/WorkspaceSettingsDialog';
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { showDialog } from '../lib/dialog';
export const openWorkspaceSettings = createFastMutation<void, string, { openSyncMenu?: boolean }>({
mutationKey: ['open_workspace_settings'],
async mutationFn({ openSyncMenu }) {
const workspaceId = getActiveWorkspaceId();
showDialog({
id: 'workspace-settings',
title: 'Workspace Settings',
size: 'md',
render({ hide }) {
return (
<WorkspaceSettingsDialog
workspaceId={workspaceId}
hide={hide}
openSyncMenu={openSyncMenu}
/>
);
},
});
},
});

View File

@@ -3,6 +3,7 @@ import { fuzzyFilter } from 'fuzzbunny';
import type { KeyboardEvent, ReactNode } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFolder } from '../commands/commands'; import { createFolder } from '../commands/commands';
import { openSettings } from '../commands/openSettings';
import { switchWorkspace } from '../commands/switchWorkspace'; import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar'; import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment'; import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
@@ -17,7 +18,6 @@ import { useEnvironments } from '../hooks/useEnvironments';
import type { HotkeyAction } from '../hooks/useHotKey'; import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey'; import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions'; import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useOpenSettings } from '../hooks/useOpenSettings';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests'; import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces'; import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
@@ -71,7 +71,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [recentRequests] = useRecentRequests(); const [recentRequests] = useRecentRequests();
const [, setSidebarHidden] = useSidebarHidden(); const [, setSidebarHidden] = useSidebarHidden();
const { baseEnvironment } = useEnvironments(); const { baseEnvironment } = useEnvironments();
const { mutate: openSettings } = useOpenSettings();
const { mutate: createHttpRequest } = useCreateHttpRequest(); const { mutate: createHttpRequest } = useCreateHttpRequest();
const { mutate: createGrpcRequest } = useCreateGrpcRequest(); const { mutate: createGrpcRequest } = useCreateGrpcRequest();
const { mutate: createEnvironment } = useCreateEnvironment(); const { mutate: createEnvironment } = useCreateEnvironment();
@@ -85,7 +84,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
key: 'settings.open', key: 'settings.open',
label: 'Open Settings', label: 'Open Settings',
action: 'settings.show', action: 'settings.show',
onSelect: openSettings, onSelect: () => openSettings.mutate(null),
}, },
{ {
key: 'app.create', key: 'app.create',
@@ -193,7 +192,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
createWorkspace, createWorkspace,
deleteRequest, deleteRequest,
httpRequestActions, httpRequestActions,
openSettings,
renameRequest, renameRequest,
sendRequest, sendRequest,
setSidebarHidden, setSidebarHidden,
@@ -406,7 +404,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
hideLabel hideLabel
leftSlot={ leftSlot={
<div className="h-md w-10 flex justify-center items-center"> <div className="h-md w-10 flex justify-center items-center">
<Icon icon="search" className="text-text-subtle" /> <Icon icon="search" color="secondary" />
</div> </div>
} }
name="command" name="command"

View File

@@ -7,7 +7,7 @@ import { useDeleteCookieJar } from '../hooks/useDeleteCookieJar';
import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar'; import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar';
import { showDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
import { showPrompt } from '../lib/prompt'; import { showPrompt } from '../lib/prompt';
import {setWorkspaceSearchParams} from "../lib/setWorkspaceSearchParams"; import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { CookieDialog } from './CookieDialog'; import { CookieDialog } from './CookieDialog';
import { Dropdown, type DropdownItem } from './core/Dropdown'; import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
@@ -76,7 +76,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
label: 'Delete', label: 'Delete',
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
color: 'danger', color: 'danger',
onSelect: () => deleteCookieJar.mutateAsync(), onSelect: deleteCookieJar.mutate,
}, },
] ]
: []) as DropdownItem[]), : []) as DropdownItem[]),
@@ -94,7 +94,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
return ( return (
<Dropdown items={items}> <Dropdown items={items}>
<IconButton size="sm" icon="cookie" title="Cookie Jar" /> <IconButton size="sm" icon="cookie" iconColor="secondary" title="Cookie Jar" />
</Dropdown> </Dropdown>
); );
}); });

View File

@@ -1,9 +1,11 @@
import { gitInit } from '@yaakapp-internal/git';
import type { WorkspaceMeta } from '@yaakapp-internal/models'; import type { WorkspaceMeta } from '@yaakapp-internal/models';
import { useState } from 'react'; import { useState } from 'react';
import { upsertWorkspace } from '../commands/upsertWorkspace'; import { upsertWorkspace } from '../commands/upsertWorkspace';
import { upsertWorkspaceMeta } from '../commands/upsertWorkspaceMeta'; import { upsertWorkspaceMeta } from '../commands/upsertWorkspaceMeta';
import { router } from '../lib/router'; import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { showErrorToast } from '../lib/toast';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
@@ -15,7 +17,10 @@ interface Props {
export function CreateWorkspaceDialog({ hide }: Props) { export function CreateWorkspaceDialog({ hide }: Props) {
const [name, setName] = useState<string>(''); const [name, setName] = useState<string>('');
const [settingSyncDir, setSettingSyncDir] = useState<string | null>(null); const [syncConfig, setSyncConfig] = useState<{
filePath: string | null;
initGit?: boolean;
}>({ filePath: null, initGit: true });
return ( return (
<VStack <VStack
@@ -33,7 +38,16 @@ export function CreateWorkspaceDialog({ hide }: Props) {
const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', { const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', {
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
upsertWorkspaceMeta.mutate({ ...workspaceMeta, settingSyncDir }); await upsertWorkspaceMeta.mutateAsync({
...workspaceMeta,
settingSyncDir: syncConfig.filePath,
});
if (syncConfig.initGit && syncConfig.filePath) {
gitInit(syncConfig.filePath).catch((err) => {
showErrorToast('git-init-error', String(err));
});
}
// Navigate to workspace // Navigate to workspace
await router.navigate({ await router.navigate({
@@ -47,8 +61,8 @@ export function CreateWorkspaceDialog({ hide }: Props) {
<PlainInput required label="Name" defaultValue={name} onChange={setName} /> <PlainInput required label="Name" defaultValue={name} onChange={setName} />
<SyncToFilesystemSetting <SyncToFilesystemSetting
onChange={setSettingSyncDir} onChange={setSyncConfig}
value={settingSyncDir} value={syncConfig}
allowNonEmptyDirectory // Will do initial import when the workspace is created allowNonEmptyDirectory // Will do initial import when the workspace is created
/> />
<Button type="submit" color="primary" className="ml-auto mt-3"> <Button type="submit" color="primary" className="ml-auto mt-3">

View File

@@ -61,7 +61,6 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
<IconButton <IconButton
size="sm" size="sm"
iconSize="md" iconSize="md"
color="custom"
title="Add sub environment" title="Add sub environment"
icon="plus_circle" icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle" iconClassName="text-text-subtlest group-hover:text-text-subtle"
@@ -166,7 +165,6 @@ const EnvironmentEditor = function ({
<Heading className="w-full flex items-center gap-1"> <Heading className="w-full flex items-center gap-1">
<div>{environment?.name}</div> <div>{environment?.name}</div>
<IconButton <IconButton
iconClassName="text-text-subtlest"
size="sm" size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'} icon={valueVisibility.value ? 'eye' : 'eye_closed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'} title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}

View File

@@ -0,0 +1,312 @@
import type { GitStatusEntry } from '@yaakapp-internal/git';
import { useGit } from '@yaakapp-internal/git';
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useMemo, useState } from 'react';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import type { CheckboxProps } from './core/Checkbox';
import { Checkbox } from './core/Checkbox';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { SplitLayout } from './core/SplitLayout';
import { HStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
interface Props {
syncDir: string;
onDone: () => void;
workspace: Workspace;
}
interface TreeNode {
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Environment | Workspace;
status: GitStatusEntry;
children: TreeNode[];
ancestors: TreeNode[];
}
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const [{ status }, { commit, add, unstage, push }] = useGit(syncDir);
const [message, setMessage] = useState<string>('');
const handleCreateCommit = async () => {
await commit.mutateAsync({ message });
onDone();
};
const handleCreateCommitAndPush = async () => {
await handleCreateCommit();
await push.mutateAsync();
onDone();
};
const entries = status.data?.entries ?? null;
const hasAddedAnything = entries?.find((s) => s.staged) != null;
const hasAnythingToAdd = entries?.find((s) => s.status !== 'current') != null;
const tree: TreeNode | null = useMemo(() => {
if (entries == null) {
return null;
}
const next = (model: TreeNode['model'], ancestors: TreeNode[]): TreeNode | null => {
const statusEntry = entries?.find((s) => s.relaPath.includes(model.id));
if (statusEntry == null) {
return null;
}
const node: TreeNode = {
model,
status: statusEntry,
children: [],
ancestors,
};
for (const entry of entries) {
const childModel = entry.next ?? entry.prev;
if (childModel == null) return null; // TODO: Is this right?
// TODO: Figure out why not all of these show up
if ('folderId' in childModel && childModel.folderId != null) {
if (childModel.folderId === model.id) {
const c = next(childModel, [...ancestors, node]);
if (c != null) node.children.push(c);
}
} else if ('workspaceId' in childModel && childModel.workspaceId === model.id) {
const c = next(childModel, [...ancestors, node]);
if (c != null) node.children.push(c);
} else {
// Do nothing
}
}
return node;
};
return next(workspace, []);
}, [entries, workspace]);
if (tree == null) {
return null;
}
if (!hasAnythingToAdd) {
return <EmptyStateText>No changes since last commit</EmptyStateText>;
}
const checkNode = (treeNode: TreeNode) => {
const checked = nodeCheckedStatus(treeNode);
const newChecked = checked === 'indeterminate' ? true : !checked;
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
// TODO: Also ensure parents are added properly
};
return (
<div className="grid grid-rows-1 h-full">
<SplitLayout
name="commit"
layout="vertical"
defaultRatio={0.3}
firstSlot={({ style }) => (
<div style={style} className="h-full overflow-y-auto -ml-1 pb-3">
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
</div>
)}
secondSlot={({ style }) => (
<div style={style} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input
className="!text-base font-sans rounded-md"
placeholder="Commit message..."
onChange={setMessage}
stateKey={null}
label="Commit message"
fullHeight
multiLine
hideLabel
/>
{commit.error && <Banner color="danger">{commit.error}</Banner>}
<HStack alignItems="center">
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
<HStack space={2} className="ml-auto">
<Button
color="secondary"
size="sm"
onClick={handleCreateCommit}
disabled={!hasAddedAnything}
isLoading={push.isPending || commit.isPending}
>
Commit
</Button>
<Button
color="primary"
size="sm"
disabled={!hasAddedAnything}
onClick={handleCreateCommitAndPush}
isLoading={push.isPending || commit.isPending}
>
Commit and Push
</Button>
</HStack>
</HStack>
</div>
)}
/>
</div>
);
}
function TreeNodeChildren({
node,
depth,
onCheck,
}: {
node: TreeNode | null;
depth: number;
onCheck: (node: TreeNode, checked: boolean) => void;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
return (
<div
className={classNames(
depth > 0 && 'pl-1 ml-[10px] border-l border-dashed border-border-subtle',
)}
>
<div className="flex gap-3 w-full h-xs">
<Checkbox
fullWidth
className="w-full hover:bg-surface-highlight rounded px-1 group"
checked={checked}
onChange={(checked) => onCheck(node, checked)}
title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
{node.model.model !== 'http_request' &&
node.model.model !== 'grpc_request' &&
node.model.model !== 'websocket_request' ? (
<Icon
color="secondary"
icon={
node.model.model === 'folder'
? 'folder'
: node.model.model === 'environment'
? 'variable'
: 'house'
}
/>
) : (
<span aria-hidden />
)}
<div className="truncate">
{fallbackRequestName(node.model)}
{/*({node.model.model})*/}
{/*({node.status.staged ? 'Y' : 'N'})*/}
</div>
{node.status.status !== 'current' && (
<InlineCode
className={classNames(
'py-0 ml-auto bg-transparent w-[6rem] text-center',
node.status.status === 'modified' && 'text-info',
node.status.status === 'added' && 'text-success',
node.status.status === 'removed' && 'text-danger',
)}
>
{node.status.status}
</InlineCode>
)}
</div>
}
/>
</div>
{node.children.map((childNode, i) => {
return (
<TreeNodeChildren
key={childNode.status.relaPath + i}
node={childNode}
depth={depth + 1}
onCheck={onCheck}
/>
);
})}
</div>
);
}
function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
let numVisited = 0;
let numChecked = 0;
let numCurrent = 0;
const visitChildren = (n: TreeNode) => {
numVisited += 1;
if (n.status.status === 'current') {
numCurrent += 1;
} else if (n.status.staged) {
numChecked += 1;
}
for (const child of n.children) {
visitChildren(child);
}
};
visitChildren(root);
if (numVisited === numChecked + numCurrent) {
return true;
} else if (numChecked === 0) {
return false;
} else {
return 'indeterminate';
}
}
function setCheckedAndChildren(
node: TreeNode,
checked: boolean,
unstage: (args: { relaPaths: string[] }) => void,
add: (args: { relaPaths: string[] }) => void,
) {
const toAdd: string[] = [];
const toUnstage: string[] = [];
const next = (node: TreeNode) => {
for (const child of node.children) {
next(child);
}
if (node.status.status === 'current') {
// Nothing required
} else if (checked && !node.status.staged) {
toAdd.push(node.status.relaPath);
} else if (!checked && node.status.staged) {
toUnstage.push(node.status.relaPath);
}
};
next(node);
if (toAdd.length > 0) add({ relaPaths: toAdd });
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: TreeNode): boolean {
if (node.status.status !== 'current') {
return true;
}
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}

View File

@@ -0,0 +1,406 @@
import { gitInit, useGit } from '@yaakapp-internal/git';
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useKeyValue } from '../hooks/useKeyValue';
import { useWorkspaceMeta } from '../hooks/useWorkspaceMeta';
import { sync } from '../init/sync';
import { showConfirm, showConfirmDelete } from '../lib/confirm';
import { showDialog } from '../lib/dialog';
import { showPrompt } from '../lib/prompt';
import { showErrorToast, showToast } from '../lib/toast';
import { Banner } from './core/Banner';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { BranchSelectionDialog } from './git/BranchSelectionDialog';
import { HistoryDialog } from './git/HistoryDialog';
import { GitCommitDialog } from './GitCommitDialog';
export function GitDropdown() {
const workspaceMeta = useWorkspaceMeta();
if (workspaceMeta == null) return null;
if (workspaceMeta.settingSyncDir == null) {
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
}
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
}
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useActiveWorkspace();
const [{ status, log }, { branch, deleteBranch, mergeBranch, push, pull, checkout }] =
useGit(syncDir);
if (workspace == null) {
return null;
}
const noRepo = status.error?.includes('not found');
if (noRepo) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={() => gitInit(syncDir)} />;
}
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
{ branch, force },
{
async onError(err) {
if (!force) {
// Checkout failed so ask user if they want to force it
const forceCheckout = await showConfirm({
id: 'git-force-checkout',
title: 'Conflicts Detected',
description:
'Your branch has conflicts. Either make a commit or force checkout to discard changes.',
confirmText: 'Force Checkout',
color: 'warning',
});
if (forceCheckout) {
tryCheckout(branch, true);
}
} else {
// Checkout failed
showErrorToast('git-checkout-error', String(err));
}
},
async onSuccess() {
showToast({
id: 'git-checkout-success',
message: (
<>
Switched branch <InlineCode>{branch}</InlineCode>
</>
),
color: 'success',
});
await sync({ force: true });
},
},
);
};
const items: DropdownItem[] = [
{
label: 'View History',
hidden: (log.data ?? []).length === 0,
leftSlot: <Icon icon="history" />,
onSelect: async () => {
showDialog({
id: 'git-history',
size: 'md',
title: 'Commit History',
render: () => <HistoryDialog log={log.data ?? []} />,
});
},
},
{
label: 'New Branch',
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
id: 'git-branch-name',
title: 'Create Branch',
label: 'Branch Name',
});
if (name) {
await branch.mutateAsync(
{ branch: name },
{
onError: (err) => {
showErrorToast('git-branch-error', String(err));
},
},
);
tryCheckout(name, false);
}
},
},
{
label: 'Merge Branch',
leftSlot: <Icon icon="merge" />,
hidden: (status.data?.branches ?? []).length <= 1,
async onSelect() {
showDialog({
id: 'git-merge',
title: 'Merge Branch',
size: 'sm',
description: (
<>
Select a branch to merge into <InlineCode>{status.data?.headRefShorthand}</InlineCode>
</>
),
render: ({ hide }) => (
<BranchSelectionDialog
selectText="Merge"
branches={(status.data?.branches ?? []).filter(
(b) => b !== status.data?.headRefShorthand,
)}
onCancel={hide}
onSelect={async (branch) => {
await mergeBranch.mutateAsync(
{ branch, force: false },
{
onSettled: hide,
onSuccess() {
showToast({
id: 'git-merged-branch',
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{' '}
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
</>
),
});
sync({ force: true });
},
onError(err) {
showErrorToast('git-merged-branch-error', String(err));
},
},
);
}}
/>
),
});
},
},
{
label: 'Delete Branch',
leftSlot: <Icon icon="trash" />,
hidden: (status.data?.branches ?? []).length <= 1,
color: 'danger',
async onSelect() {
const currentBranch = status.data?.headRefShorthand;
if (currentBranch == null) return;
const confirmed = await showConfirmDelete({
id: 'git-delete-branch',
title: 'Delete Branch',
description: (
<>
Permanently delete <InlineCode>{currentBranch}</InlineCode>?
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch: currentBranch },
{
onError(err) {
showErrorToast('git-delete-branch-error', String(err));
},
async onSuccess() {
await sync({ force: true });
},
},
);
}
},
},
{ type: 'separator' },
{
label: 'Push',
hidden: (status.data?.origins ?? []).length === 0,
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
const message = await push.mutateAsync();
if (message === 'nothing_to_push') {
showToast({ id: 'push-success', message: 'Nothing to push', color: 'info' });
} else {
showToast({ id: 'push-success', message: 'Push successful', color: 'success' });
}
},
},
{
label: 'Pull',
hidden: (status.data?.origins ?? []).length === 0,
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
const result = await pull.mutateAsync(undefined, {
onError(err) {
showErrorToast('git-pull-error', String(err));
},
});
if (result.receivedObjects > 0) {
showToast({
id: 'git-pull-success',
message: `Pulled ${result.receivedObjects} objects`,
color: 'success',
});
await sync({ force: true });
} else {
showToast({ id: 'git-pull-success', message: 'Already up to date', color: 'info' });
}
},
},
{
label: 'Commit',
leftSlot: <Icon icon="git_branch" />,
onSelect() {
showDialog({
id: 'commit',
title: 'Commit Changes',
size: 'full',
className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]',
render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
),
});
},
},
{ type: 'separator', label: 'Branches', hidden: (status.data?.branches ?? []).length < 1 },
...(status.data?.branches ?? []).map((branch) => {
const isCurrent = status.data?.headRefShorthand === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
};
}),
];
return (
<Dropdown fullWidth items={items}>
<GitMenuButton>
{noRepo ? 'Configure Git' : <InlineCode>{status.data?.headRefShorthand}</InlineCode>}
<Icon icon="git_branch" size="sm" />
</GitMenuButton>
</Dropdown>
);
}
const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {
return (
<button
ref={ref}
className={classNames(
className,
'px-3 h-md border-t border-border flex items-center justify-between text-text-subtle',
)}
{...props}
/>
);
},
);
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: 'setup_sync',
fallback: {},
});
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
return null;
}
const banner = (
<Banner color="info">
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
Git collaboration.
</Banner>
);
return (
<Dropdown
fullWidth
items={[
{
type: 'content',
label: banner,
},
{
label: 'Open Workspace Settings',
leftSlot: <Icon icon="settings" />,
onSelect() {
openWorkspaceSettings.mutate({ openSyncMenu: true });
},
},
{
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: 'hide-sync-menu-prompt',
title: 'Hide Setup Message',
description: 'You can configure filesystem sync or Git it in the workspace settings',
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="wrench" />
<div className="truncate">Setup FS Sync or Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}
function SetupGitDropdown({
workspaceId,
initRepo,
}: {
workspaceId: string;
initRepo: () => void;
}) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: 'setup_git_repo',
fallback: {},
});
if (hidden == null || hidden[workspaceId]) {
return null;
}
const banner = <Banner color="info">Initialize local repo to start versioning with Git</Banner>;
return (
<Dropdown
fullWidth
items={[
{ type: 'content', label: banner },
{
label: 'Initialize Git Repo',
leftSlot: <Icon icon="magic_wand" />,
onSelect: initRepo,
},
{
color: 'warning',
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: 'hide-git-init-prompt',
title: 'Hide Git Setup',
description: 'You can initialize a git repo outside of Yaak to bring this back',
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="folder_git" />
<div className="truncate">Setup Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}

View File

@@ -214,16 +214,16 @@ function EventRow({
)} )}
> >
<Icon <Icon
className={ color={
eventType === 'server_message' eventType === 'server_message'
? 'text-info' ? 'info'
: eventType === 'client_message' : eventType === 'client_message'
? 'text-primary' ? 'primary'
: eventType === 'error' || (status != null && status > 0) : eventType === 'error' || (status != null && status > 0)
? 'text-danger' ? 'danger'
: eventType === 'connection_end' : eventType === 'connection_end'
? 'text-success' ? 'success'
: 'text-text-subtle' : undefined
} }
title={ title={
eventType === 'server_message' eventType === 'server_message'

View File

@@ -237,14 +237,14 @@ export function GrpcConnectionSetupPane({
{ {
label: 'Refresh', label: 'Refresh',
type: 'default', type: 'default',
leftSlot: <Icon className="text-text-subtlest" size="sm" icon="refresh" />, leftSlot: <Icon size="sm" icon="refresh" />,
}, },
]} ]}
> >
<Button <Button
size="sm" size="sm"
variant="border" variant="border"
rightSlot={<Icon className="text-text-subtlest" size="sm" icon="chevron_down" />} rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null} disabled={isStreaming || services == null}
className={classNames( className={classNames(
'font-mono text-editor min-w-[5rem] !ring-0', 'font-mono text-editor min-w-[5rem] !ring-0',

View File

@@ -3,18 +3,26 @@ import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license'; import { useLicense } from '@yaakapp-internal/license';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { appInfo } from '../hooks/useAppInfo'; import { appInfo } from '../hooks/useAppInfo';
import { useOpenSettings } from '../hooks/useOpenSettings';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
import { Button } from './core/Button'; import { Button } from './core/Button';
import {HStack} from "./core/Stacks";
import { SettingsTab } from './Settings/SettingsTab';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { HStack } from './core/Stacks';
import { openSettings } from '../commands/openSettings';
import {SettingsTab} from "./Settings/SettingsTab";
const details: Record< const details: Record<
LicenseCheckStatus['type'] | 'dev' | 'beta', LicenseCheckStatus['type'] | 'dev' | 'beta',
{ label: ReactNode; color: ButtonProps['color'] } | null { label: ReactNode; color: ButtonProps['color'] } | null
> = { > = {
beta: { label: <HStack space={1}><span>Beta Feedback</span><Icon size="xs" icon='external_link'/></HStack>, color: 'info' }, beta: {
label: (
<HStack space={1}>
<span>Beta Feedback</span>
<Icon size="xs" icon="external_link" />
</HStack>
),
color: 'info',
},
dev: { label: 'Develop', color: 'secondary' }, dev: { label: 'Develop', color: 'secondary' },
commercial_use: null, commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' }, invalid_license: { label: 'License Error', color: 'danger' },
@@ -23,7 +31,6 @@ const details: Record<
}; };
export function LicenseBadge() { export function LicenseBadge() {
const openSettings = useOpenSettings(SettingsTab.License);
const { check } = useLicense(); const { check } = useLicense();
if (check.data == null) { if (check.data == null) {
@@ -49,7 +56,7 @@ export function LicenseBadge() {
if (checkType === 'beta') { if (checkType === 'beta') {
await openUrl('https://feedback.yaak.app'); await openUrl('https://feedback.yaak.app');
} else { } else {
openSettings.mutate(); openSettings.mutate(SettingsTab.License);
} }
}} }}
color={detail.color} color={detail.color}

View File

@@ -177,7 +177,7 @@ export function SettingsAppearance() {
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto" className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
> >
<HStack className="text" space={1.5}> <HStack className="text" space={1.5}>
<Icon icon={appearance === 'dark' ? 'moon' : 'sun'} className="text-text-subtle" /> <Icon icon={appearance === 'dark' ? 'moon' : 'sun'} />
<strong>{activeTheme.active.name}</strong> <strong>{activeTheme.active.name}</strong>
<em>(preview)</em> <em>(preview)</em>
</HStack> </HStack>

View File

@@ -107,7 +107,6 @@ function PluginInfo({ plugin }: { plugin: Plugin }) {
size="sm" size="sm"
icon="trash" icon="trash"
title="Uninstall plugin" title="Uninstall plugin"
className="text-text-subtlest"
event="plugin.delete" event="plugin.delete"
onClick={() => deletePlugin.mutate()} onClick={() => deletePlugin.mutate()}
/> />

View File

@@ -1,11 +1,11 @@
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { useRef } from 'react'; import { useRef } from 'react';
import { openSettings } from '../commands/openSettings';
import { useAppInfo } from '../hooks/useAppInfo'; import { useAppInfo } from '../hooks/useAppInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates'; import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData'; import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData'; import { useImportData } from '../hooks/useImportData';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useOpenSettings } from '../hooks/useOpenSettings';
import { showDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
import type { DropdownRef } from './core/Dropdown'; import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
@@ -19,9 +19,8 @@ export function SettingsDropdown() {
const appInfo = useAppInfo(); const appInfo = useAppInfo();
const dropdownRef = useRef<DropdownRef>(null); const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates(); const checkForUpdates = useCheckForUpdates();
const openSettings = useOpenSettings();
useListenToTauriEvent('settings', () => openSettings.mutate()); useListenToTauriEvent('settings', () => openSettings.mutate(null));
return ( return (
<Dropdown <Dropdown
@@ -31,7 +30,7 @@ export function SettingsDropdown() {
label: 'Settings', label: 'Settings',
hotKeyAction: 'settings.show', hotKeyAction: 'settings.show',
leftSlot: <Icon icon="settings" />, leftSlot: <Icon icon="settings" />,
onSelect: openSettings.mutate, onSelect: () => openSettings.mutate(null),
}, },
{ {
label: 'Keyboard shortcuts', label: 'Keyboard shortcuts',
@@ -76,7 +75,13 @@ export function SettingsDropdown() {
}, },
]} ]}
> >
<IconButton size="sm" title="Main Menu" icon="settings" className="pointer-events-auto" /> <IconButton
size="sm"
title="Main Menu"
icon="settings"
iconColor="secondary"
className="pointer-events-auto"
/>
</Dropdown> </Dropdown>
); );
} }

View File

@@ -1,38 +1,38 @@
import { readDir } from '@tauri-apps/plugin-fs'; import { readDir } from '@tauri-apps/plugin-fs';
import { useState } from 'react'; import { useState } from 'react';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Checkbox } from './core/Checkbox';
import { VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
import { SelectFile } from './SelectFile'; import { SelectFile } from './SelectFile';
export interface SyncToFilesystemSettingProps { export interface SyncToFilesystemSettingProps {
onChange: (filePath: string | null) => void; onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
value: string | null; value: { filePath: string | null; initGit?: boolean };
allowNonEmptyDirectory?: boolean; allowNonEmptyDirectory?: boolean;
forceOpen?: boolean;
} }
export function SyncToFilesystemSetting({ export function SyncToFilesystemSetting({
onChange, onChange,
value, value,
allowNonEmptyDirectory, allowNonEmptyDirectory,
forceOpen,
}: SyncToFilesystemSettingProps) { }: SyncToFilesystemSettingProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
return ( return (
<details open={value != null} className="w-full"> <details open={forceOpen || value != null} className="w-full">
<summary>Sync to filesystem</summary> <summary>Data directory {typeof value.initGit === 'boolean' && ' and Git'}</summary>
<VStack className="my-2" space={3}> <VStack className="my-2" space={3}>
<Banner color="info"> <Banner color="info">
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup Sync workspace data to folder as plain text files, ideal for backup and Git collaboration.
and Git collaboration.
</Banner> </Banner>
{error && <div className="text-danger">{error}</div>} {error && <div className="text-danger">{error}</div>}
<SelectFile <SelectFile
directory directory
color="primary"
size="xs" size="xs"
noun="Directory" noun="Directory"
filePath={value} filePath={value.filePath}
onChange={async ({ filePath }) => { onChange={async ({ filePath }) => {
if (filePath != null) { if (filePath != null) {
const files = await readDir(filePath); const files = await readDir(filePath);
@@ -42,9 +42,17 @@ export function SyncToFilesystemSetting({
} }
} }
onChange(filePath); onChange({ ...value, filePath });
}} }}
/> />
{value.filePath && typeof value.initGit === 'boolean' && (
<Checkbox
checked={value.initGit}
onChange={(initGit) => onChange({ ...value, initGit })}
title="Initialize Git Repo"
/>
)}
</VStack> </VStack>
</details> </details>
); );

View File

@@ -7,6 +7,7 @@ import { Portal } from './Portal';
export type ToastInstance = { export type ToastInstance = {
id: string; id: string;
uniqueKey: string;
message: ReactNode; message: ReactNode;
timeout: 3000 | 5000 | 8000 | null; timeout: 3000 | 5000 | 8000 | null;
onClose?: ToastProps['onClose']; onClose?: ToastProps['onClose'];
@@ -18,18 +19,21 @@ export const Toasts = () => {
<Portal name="toasts"> <Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50"> <div className="absolute right-0 bottom-0 z-50">
<AnimatePresence> <AnimatePresence>
{toasts.map(({ message, ...props }: ToastInstance) => ( {toasts.map((toast: ToastInstance) => {
<Toast const { message, uniqueKey, ...props } = toast;
key={props.id} return (
open <Toast
{...props} key={uniqueKey}
// We call onClose inside actions.hide instead of passing to toast so that open
// it gets called from external close calls as well {...props}
onClose={() => hideToast(props.id)} // We call onClose inside actions.hide instead of passing to toast so that
> // it gets called from external close calls as well
{message} onClose={() => hideToast(toast)}
</Toast> >
))} {message}
</Toast>
);
})}
</AnimatePresence> </AnimatePresence>
</div> </div>
</Portal> </Portal>

View File

@@ -110,6 +110,7 @@ export const UrlBar = memo(function UrlBar({
title="Send Request" title="Send Request"
type="submit" type="submit"
className="w-8 mr-0.5 !h-full" className="w-8 mr-0.5 !h-full"
iconColor="secondary"
icon={isLoading ? 'x' : submitIcon} icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send" hotkeyAction="http_request.send"
/> />

View File

@@ -235,6 +235,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
size="xs" size="xs"
title="Close connection" title="Close connection"
icon="x" icon="x"
iconColor="secondary"
className="w-8 mr-0.5 !h-full" className="w-8 mr-0.5 !h-full"
onClick={handleCancel} onClick={handleCancel}
/> />

View File

@@ -212,9 +212,7 @@ function EventRow({
)} )}
> >
<Icon <Icon
className={classNames( color={messageType === 'close' ? 'secondary' : isServer ? 'info' : 'primary'}
messageType === 'close' ? 'text-secondary' : isServer ? 'text-info' : 'text-primary',
)}
icon={ icon={
messageType === 'close' messageType === 'close'
? 'info' ? 'info'

View File

@@ -2,6 +2,7 @@ import { revealItemInDir } from '@tauri-apps/plugin-opener';
import classNames from 'classnames'; import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir'; import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { switchWorkspace } from '../commands/switchWorkspace'; import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace'; import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
@@ -19,7 +20,6 @@ import { Icon } from './core/Icon';
import type { RadioDropdownItem } from './core/RadioDropdown'; import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown'; import { RadioDropdown } from './core/RadioDropdown';
import { SwitchWorkspaceDialog } from './SwitchWorkspaceDialog'; import { SwitchWorkspaceDialog } from './SwitchWorkspaceDialog';
import { WorkspaceSettingsDialog } from './WorkspaceSettingsDialog';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>; type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
@@ -49,21 +49,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
label: 'Workspace Settings', label: 'Workspace Settings',
leftSlot: <Icon icon="settings" />, leftSlot: <Icon icon="settings" />,
hotKeyAction: 'workspace_settings.show', hotKeyAction: 'workspace_settings.show',
onSelect: async () => { onSelect: () => openWorkspaceSettings.mutate({}),
showDialog({
id: 'workspace-settings',
title: 'Workspace Settings',
size: 'md',
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspace?.id ?? null} hide={hide} />
),
});
},
}, },
{ {
label: revealInFinderText, label: revealInFinderText,
hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null, hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
leftSlot: <Icon icon="folder_open" />, leftSlot: <Icon icon="folder_symlink" />,
onSelect: async () => { onSelect: async () => {
if (workspaceMeta?.settingSyncDir == null) return; if (workspaceMeta?.settingSyncDir == null) return;
await revealItemInDir(workspaceMeta.settingSyncDir); await revealItemInDir(workspaceMeta.settingSyncDir);
@@ -82,8 +73,8 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
onSelect: createWorkspace, onSelect: createWorkspace,
}, },
{ {
label: 'Open Workspace', label: 'Open Existing Workspace',
leftSlot: <Icon icon="folder" />, leftSlot: <Icon icon="folder_open" />,
onSelect: openWorkspaceFromSyncDir.mutate, onSelect: openWorkspaceFromSyncDir.mutate,
}, },
]; ];

View File

@@ -32,7 +32,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<CookieDropdown /> <CookieDropdown />
<HStack className="min-w-0"> <HStack className="min-w-0">
<WorkspaceActionsDropdown /> <WorkspaceActionsDropdown />
<Icon icon="chevron_right" className="text-text-subtle" /> <Icon icon="chevron_right" color="secondary" />
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" /> <EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
</HStack> </HStack>
</HStack> </HStack>
@@ -47,6 +47,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
title="Search or execute a command" title="Search or execute a command"
size="sm" size="sm"
event="search" event="search"
iconColor="secondary"
onClick={togglePalette} onClick={togglePalette}
/> />
<SettingsDropdown /> <SettingsDropdown />

View File

@@ -15,9 +15,10 @@ import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
interface Props { interface Props {
workspaceId: string | null; workspaceId: string | null;
hide: () => void; hide: () => void;
openSyncMenu?: boolean;
} }
export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) { export function WorkspaceSettingsDialog({ workspaceId, hide, openSyncMenu }: Props) {
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
const workspace = workspaces.find((w) => w.id === workspaceId); const workspace = workspaces.find((w) => w.id === workspaceId);
const workspaceMeta = useWorkspaceMeta(); const workspaceMeta = useWorkspaceMeta();
@@ -60,10 +61,11 @@ export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
<VStack space={6} className="mt-3 w-full" alignItems="start"> <VStack space={6} className="mt-3 w-full" alignItems="start">
<SyncToFilesystemSetting <SyncToFilesystemSetting
value={workspaceMeta.settingSyncDir} value={{ filePath: workspaceMeta.settingSyncDir }}
onChange={(settingSyncDir) => forceOpen={openSyncMenu}
upsertWorkspaceMeta.mutate({ ...workspaceMeta, settingSyncDir }) onChange={({ filePath }) => {
} upsertWorkspaceMeta.mutate({ ...workspaceMeta, settingSyncDir: filePath });
}}
/> />
<Separator /> <Separator />
<Button <Button

View File

@@ -1,3 +1,4 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames'; import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react'; import { forwardRef, useImperativeHandle, useRef } from 'react';
@@ -9,16 +10,7 @@ import { LoadingIcon } from './LoadingIcon';
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onChange'> & { export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onChange'> & {
innerClassName?: string; innerClassName?: string;
color?: color?: Color | 'custom' | 'default';
| 'custom'
| 'default'
| 'secondary'
| 'primary'
| 'info'
| 'success'
| 'notice'
| 'warning'
| 'danger';
variant?: 'border' | 'solid'; variant?: 'border' | 'solid';
isLoading?: boolean; isLoading?: boolean;
size?: '2xs' | 'xs' | 'sm' | 'md'; size?: '2xs' | 'xs' | 'sm' | 'md';
@@ -59,6 +51,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join(''); const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join('');
const fullTitle = hotkeyTrigger ? `${title ?? ''} ${hotkeyTrigger}`.trim() : title; const fullTitle = hotkeyTrigger ? `${title ?? ''} ${hotkeyTrigger}`.trim() : title;
if (isLoading) {
disabled = true;
}
const classes = classNames( const classes = classNames(
className, className,
'x-theme-button', 'x-theme-button',
@@ -110,7 +106,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={buttonRef} ref={buttonRef}
type={type} type={type}
className={classes} className={classes}
disabled={disabled || isLoading} disabled={disabled}
onClick={(e) => { onClick={(e) => {
onClick?.(e); onClick?.(e);
if (event != null) { if (event != null) {

View File

@@ -12,6 +12,7 @@ export interface CheckboxProps {
disabled?: boolean; disabled?: boolean;
inputWrapperClassName?: string; inputWrapperClassName?: string;
hideLabel?: boolean; hideLabel?: boolean;
fullWidth?: boolean;
event?: string; event?: string;
} }
@@ -23,6 +24,7 @@ export function Checkbox({
disabled, disabled,
title, title,
hideLabel, hideLabel,
fullWidth,
event, event,
}: CheckboxProps) { }: CheckboxProps) {
return ( return (
@@ -52,7 +54,9 @@ export function Checkbox({
/> />
</div> </div>
</div> </div>
<span className={classNames(disabled && 'opacity-disabled')}>{!hideLabel && title}</span> <div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{!hideLabel && title}
</div>
</HStack> </HStack>
); );
} }

View File

@@ -1,25 +1,15 @@
import type { ButtonProps } from './Button'; import type { Color } from '@yaakapp-internal/plugins';
import { Button } from './Button'; import { Button } from './Button';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
export interface ConfirmProps { export interface ConfirmProps {
onHide: () => void; onHide: () => void;
onResult: (result: boolean) => void; onResult: (result: boolean) => void;
variant?: 'delete' | 'confirm';
confirmText?: string; confirmText?: string;
color?: Color;
} }
const colors: Record<NonNullable<ConfirmProps['variant']>, ButtonProps['color']> = { export function Confirm({ onHide, onResult, confirmText, color = 'primary' }: ConfirmProps) {
delete: 'danger',
confirm: 'primary',
};
const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> = {
delete: 'Delete',
confirm: 'Confirm',
};
export function Confirm({ onHide, onResult, confirmText, variant = 'confirm' }: ConfirmProps) {
const handleHide = () => { const handleHide = () => {
onResult(false); onResult(false);
onHide(); onHide();
@@ -32,8 +22,8 @@ export function Confirm({ onHide, onResult, confirmText, variant = 'confirm' }:
return ( return (
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse"> <HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button color={colors[variant]} onClick={handleSuccess}> <Button color={color} onClick={handleSuccess}>
{confirmText ?? confirmButtonTexts[variant]} {confirmText ?? 'Confirm'}
</Button> </Button>
<Button onClick={handleHide} variant="border"> <Button onClick={handleHide} variant="border">
Cancel Cancel

View File

@@ -36,17 +36,23 @@ import { HotKey } from './HotKey';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { HStack, VStack } from './Stacks'; import { HStack, VStack } from './Stacks';
import { LoadingIcon } from './LoadingIcon';
export type DropdownItemSeparator = { export type DropdownItemSeparator = {
type: 'separator'; type: 'separator';
label?: string; label?: ReactNode;
hidden?: boolean;
};
export type DropdownItemContent = {
type: 'content';
label?: ReactNode;
hidden?: boolean; hidden?: boolean;
}; };
export type DropdownItemDefault = { export type DropdownItemDefault = {
type?: 'default'; type?: 'default';
label: ReactNode; label: ReactNode;
keepOpen?: boolean;
hotKeyAction?: HotkeyAction; hotKeyAction?: HotkeyAction;
hotKeyLabelOnly?: boolean; hotKeyLabelOnly?: boolean;
color?: 'default' | 'danger' | 'info' | 'warning' | 'notice'; color?: 'default' | 'danger' | 'info' | 'warning' | 'notice';
@@ -54,10 +60,11 @@ export type DropdownItemDefault = {
hidden?: boolean; hidden?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
onSelect?: () => void; waitForOnSelect?: boolean;
onSelect?: () => void | Promise<void>;
}; };
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator; export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
export interface DropdownProps { export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>; children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
@@ -374,14 +381,20 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
); );
const handleSelect = useCallback( const handleSelect = useCallback(
(i: DropdownItem) => { async (item: DropdownItem) => {
if (i.type !== 'separator' && !i.keepOpen) { if (!('onSelect' in item) || !item.onSelect) return;
handleClose();
}
setSelectedIndex(null); setSelectedIndex(null);
if (i.type !== 'separator' && typeof i.onSelect === 'function') {
i.onSelect(); const promise = item.onSelect();
if (item.waitForOnSelect) {
try {
await promise;
} catch {
// Nothing
}
} }
handleClose();
}, },
[handleClose, setSelectedIndex], [handleClose, setSelectedIndex],
); );
@@ -391,10 +404,10 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
close: handleClose, close: handleClose,
prev: handlePrev, prev: handlePrev,
next: handleNext, next: handleNext,
select() { async select() {
const item = items[selectedIndexRef.current ?? -1] ?? null; const item = items[selectedIndexRef.current ?? -1] ?? null;
if (!item) return; if (!item) return;
handleSelect(item); await handleSelect(item);
}, },
}; };
}, [handleClose, handleNext, handlePrev, handleSelect, items]); }, [handleClose, handleNext, handlePrev, handleSelect, items]);
@@ -466,6 +479,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
{items.map( {items.map(
(item, i) => (item, i) =>
item.type !== 'separator' && item.type !== 'separator' &&
item.type !== 'content' &&
!item.hotKeyLabelOnly && !item.hotKeyLabelOnly &&
item.hotKeyAction && ( item.hotKeyAction && (
<MenuItemHotKey <MenuItemHotKey
@@ -519,7 +533,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
space={2} space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs" className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
> >
<Icon icon="search" size="xs" className="text-text-subtle" /> <Icon icon="search" size="xs" />
<div className="text">{filter}</div> <div className="text">{filter}</div>
</HStack> </HStack>
)} )}
@@ -537,6 +551,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
</Separator> </Separator>
); );
} }
if (item.type === 'content') {
return (
<div key={i} className={classNames('my-1.5 mx-2 max-w-xs')}>
{item.label}
</div>
);
}
return ( return (
<MenuItem <MenuItem
focused={i === selectedIndex} focused={i === selectedIndex}
@@ -559,13 +580,19 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
interface MenuItemProps { interface MenuItemProps {
className?: string; className?: string;
item: DropdownItemDefault; item: DropdownItemDefault;
onSelect: (item: DropdownItemDefault) => void; onSelect: (item: DropdownItemDefault) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void; onFocus: (item: DropdownItemDefault) => void;
focused: boolean; focused: boolean;
} }
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) { function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]); const [isLoading, setIsLoading] = useState(false);
const handleClick = useCallback(async () => {
if (item.waitForOnSelect) setIsLoading(true);
await onSelect?.(item);
if (item.waitForOnSelect) setIsLoading(false);
}, [item, onSelect]);
const handleFocus = useCallback( const handleFocus = useCallback(
(e: ReactFocusEvent<HTMLButtonElement>) => { (e: ReactFocusEvent<HTMLButtonElement>) => {
e.stopPropagation(); // Don't trigger focus on any parents e.stopPropagation(); // Don't trigger focus on any parents
@@ -598,7 +625,11 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
onClick={handleClick} onClick={handleClick}
justify="start" justify="start"
leftSlot={ leftSlot={
item.leftSlot && <div className="pr-2 flex justify-start opacity-70">{item.leftSlot}</div> (isLoading || item.leftSlot) && (
<div className={classNames('pr-2 flex justify-start opacity-70')}>
{isLoading ? <LoadingIcon /> : item.leftSlot}
</div>
)
} }
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>} rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="!text-left" innerClassName="!text-left"

View File

@@ -1,3 +1,4 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames'; import classNames from 'classnames';
import * as lucide from 'lucide-react'; import * as lucide from 'lucide-react';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
@@ -14,9 +15,11 @@ const icons = {
arrow_big_up_dash: lucide.ArrowBigUpDashIcon, arrow_big_up_dash: lucide.ArrowBigUpDashIcon,
arrow_down: lucide.ArrowDownIcon, arrow_down: lucide.ArrowDownIcon,
arrow_down_to_dot: lucide.ArrowDownToDotIcon, arrow_down_to_dot: lucide.ArrowDownToDotIcon,
arrow_down_to_line: lucide.ArrowDownToLineIcon,
arrow_up: lucide.ArrowUpIcon, arrow_up: lucide.ArrowUpIcon,
arrow_up_down: lucide.ArrowUpDownIcon, arrow_up_down: lucide.ArrowUpDownIcon,
arrow_up_from_dot: lucide.ArrowUpFromDotIcon, arrow_up_from_dot: lucide.ArrowUpFromDotIcon,
arrow_up_from_line: lucide.ArrowUpFromLineIcon,
badge_check: lucide.BadgeCheckIcon, badge_check: lucide.BadgeCheckIcon,
box: lucide.BoxIcon, box: lucide.BoxIcon,
cake: lucide.CakeIcon, cake: lucide.CakeIcon,
@@ -42,11 +45,14 @@ const icons = {
filter: lucide.FilterIcon, filter: lucide.FilterIcon,
flask: lucide.FlaskConicalIcon, flask: lucide.FlaskConicalIcon,
folder: lucide.FolderIcon, folder: lucide.FolderIcon,
folder_git: lucide.FolderGitIcon,
folder_input: lucide.FolderInputIcon, folder_input: lucide.FolderInputIcon,
folder_open: lucide.FolderOpenIcon, folder_open: lucide.FolderOpenIcon,
folder_output: lucide.FolderOutputIcon, folder_output: lucide.FolderOutputIcon,
folder_symlink: lucide.FolderSymlinkIcon,
folder_sync: lucide.FolderSyncIcon, folder_sync: lucide.FolderSyncIcon,
git_branch: lucide.GitBranchIcon, git_branch: lucide.GitBranchIcon,
git_branch_plus: lucide.GitBranchPlusIcon,
git_commit: lucide.GitCommitIcon, git_commit: lucide.GitCommitIcon,
git_commit_vertical: lucide.GitCommitVerticalIcon, git_commit_vertical: lucide.GitCommitVerticalIcon,
git_pull_request: lucide.GitPullRequestIcon, git_pull_request: lucide.GitPullRequestIcon,
@@ -63,6 +69,7 @@ const icons = {
left_panel_visible: lucide.PanelLeftCloseIcon, left_panel_visible: lucide.PanelLeftCloseIcon,
lock: lucide.LockIcon, lock: lucide.LockIcon,
magic_wand: lucide.Wand2Icon, magic_wand: lucide.Wand2Icon,
merge: lucide.MergeIcon,
minus: lucide.MinusIcon, minus: lucide.MinusIcon,
minus_circle: lucide.MinusCircleIcon, minus_circle: lucide.MinusCircleIcon,
moon: lucide.MoonIcon, moon: lucide.MoonIcon,
@@ -86,6 +93,8 @@ const icons = {
unpin: lucide.PinOffIcon, unpin: lucide.PinOffIcon,
update: lucide.RefreshCcwIcon, update: lucide.RefreshCcwIcon,
upload: lucide.UploadIcon, upload: lucide.UploadIcon,
variable: lucide.VariableIcon,
wrench: lucide.WrenchIcon,
x: lucide.XIcon, x: lucide.XIcon,
_unknown: lucide.ShieldAlertIcon, _unknown: lucide.ShieldAlertIcon,
@@ -98,22 +107,38 @@ export interface IconProps {
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
spin?: boolean; spin?: boolean;
title?: string; title?: string;
color?: Color | 'custom' | 'default';
} }
export const Icon = memo(function Icon({ icon, spin, size = 'md', className, title }: IconProps) { export const Icon = memo(function Icon({
icon,
color = 'default',
spin,
size = 'md',
className,
title,
}: IconProps) {
const Component = icons[icon] ?? icons._unknown; const Component = icons[icon] ?? icons._unknown;
return ( return (
<Component <Component
title={title} title={title}
className={classNames( className={classNames(
className, className,
'text-inherit flex-shrink-0', 'flex-shrink-0',
size === 'xl' && 'h-6 w-6', size === 'xl' && 'h-6 w-6',
size === 'lg' && 'h-5 w-5', size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4', size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3.5 w-3.5', size === 'sm' && 'h-3.5 w-3.5',
size === 'xs' && 'h-3 w-3', size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5', size === '2xs' && 'h-2.5 w-2.5',
color === 'default' && 'inherit',
color === 'danger' && 'text-danger',
color === 'warning' && 'text-warning',
color === 'notice' && 'text-notice',
color === 'info' && 'text-info',
color === 'success' && 'text-success',
color === 'primary' && 'text-primary',
color === 'secondary' && 'text-secondary',
spin && 'animate-spin', spin && 'animate-spin',
)} )}
/> />

View File

@@ -12,6 +12,7 @@ export type IconButtonProps = IconProps &
showConfirm?: boolean; showConfirm?: boolean;
iconClassName?: string; iconClassName?: string;
iconSize?: IconProps['size']; iconSize?: IconProps['size'];
iconColor?: IconProps['color'];
title: string; title: string;
showBadge?: boolean; showBadge?: boolean;
}; };
@@ -29,6 +30,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
size = 'md', size = 'md',
iconSize, iconSize,
showBadge, showBadge,
iconColor,
...props ...props
}: IconButtonProps, }: IconButtonProps,
ref, ref,
@@ -47,7 +49,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
ref={ref} ref={ref}
aria-hidden={icon === 'empty'} aria-hidden={icon === 'empty'}
disabled={icon === 'empty'} disabled={icon === 'empty'}
tabIndex={tabIndex ?? icon === 'empty' ? -1 : undefined} tabIndex={(tabIndex ?? icon === 'empty') ? -1 : undefined}
onClick={handleClick} onClick={handleClick}
innerClassName="flex items-center justify-center" innerClassName="flex items-center justify-center"
size={size} size={size}
@@ -56,8 +58,6 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
className, className,
'group/button relative flex-shrink-0', 'group/button relative flex-shrink-0',
'!px-0', '!px-0',
color === 'custom' && 'text-text-subtle',
color === 'default' && 'text-text-subtle',
size === 'md' && 'w-md', size === 'md' && 'w-md',
size === 'sm' && 'w-sm', size === 'sm' && 'w-sm',
size === 'xs' && 'w-xs', size === 'xs' && 'w-xs',
@@ -74,11 +74,11 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
size={iconSize} size={iconSize}
icon={confirmed ? 'check' : icon} icon={confirmed ? 'check' : icon}
spin={spin} spin={spin}
color={confirmed ? 'success' : iconColor}
className={classNames( className={classNames(
iconClassName, iconClassName,
'group-hover/button:text', 'group-hover/button:text-text',
props.disabled && 'opacity-70', props.disabled && 'opacity-70',
confirmed && 'text-green-600',
)} )}
/> />
</Button> </Button>

View File

@@ -46,6 +46,7 @@ export type InputProps = Pick<
required?: boolean; required?: boolean;
wrapLines?: boolean; wrapLines?: boolean;
multiLine?: boolean; multiLine?: boolean;
fullHeight?: boolean;
stateKey: EditorProps['stateKey']; stateKey: EditorProps['stateKey'];
}; };
@@ -56,6 +57,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
inputWrapperClassName, inputWrapperClassName,
defaultValue, defaultValue,
forceUpdateKey, forceUpdateKey,
fullHeight,
hideLabel, hideLabel,
label, label,
labelClassName, labelClassName,
@@ -148,8 +150,9 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
<div <div
ref={wrapperRef} ref={wrapperRef}
className={classNames( className={classNames(
'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent 'pointer-events-auto', // Just in case we're placing in disabled parent
'w-full',
fullHeight && 'h-full',
labelPosition === 'left' && 'flex items-center gap-2', labelPosition === 'left' && 'flex items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5', labelPosition === 'top' && 'flex-row gap-0.5',
)} )}
@@ -166,6 +169,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
alignItems="stretch" alignItems="stretch"
className={classNames( className={classNames(
containerClassName, containerClassName,
fullHeight && 'h-full',
'x-theme-input', 'x-theme-input',
'relative w-full rounded-md text', 'relative w-full rounded-md text',
'border', 'border',
@@ -182,6 +186,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
className={classNames( className={classNames(
inputWrapperClassName, inputWrapperClassName,
'w-full min-w-0 px-2', 'w-full min-w-0 px-2',
fullHeight && 'h-full',
leftSlot && 'pl-0.5 -ml-2', leftSlot && 'pl-0.5 -ml-2',
rightSlot && 'pr-0.5 -mr-2', rightSlot && 'pr-0.5 -mr-2',
)} )}
@@ -218,8 +223,11 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
<IconButton <IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`} title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs" size="xs"
className={classNames("mr-0.5 group/obscure !h-auto my-0.5", disabled && 'opacity-disabled')} className={classNames(
iconClassName="text-text-subtle group-hover/obscure:text" 'mr-0.5 group/obscure !h-auto my-0.5',
disabled && 'opacity-disabled',
)}
iconClassName="group-hover/obscure:text"
iconSize="sm" iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'} icon={obscured ? 'eye' : 'eye_closed'}
onClick={() => setObscured((o) => !o)} onClick={() => setObscured((o) => !o)}

View File

@@ -104,7 +104,7 @@ export const JsonAttributeTree = ({
icon="chevron_right" icon="chevron_right"
className={classNames( className={classNames(
'left-0 absolute transition-transform flex items-center', 'left-0 absolute transition-transform flex items-center',
'text-text-subtlest group-hover:text-text-subtle', 'group-hover:text-text-subtle',
isExpanded ? 'rotate-90' : '', isExpanded ? 'rotate-90' : '',
)} )}
/> />

View File

@@ -30,7 +30,7 @@ export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOr
title={useBulk ? 'Enable form edit' : 'Enable bulk edit'} title={useBulk ? 'Enable form edit' : 'Enable bulk edit'}
className={classNames( className={classNames(
'transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow', 'transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow',
'bg-surface text-text-subtle hover:text group-hover/wrapper:opacity-100', 'bg-surface hover:text group-hover/wrapper:opacity-100',
)} )}
onClick={() => setUseBulk((b) => !b)} onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? 'table' : 'file_code'} icon={useBulk ? 'table' : 'file_code'}

View File

@@ -148,7 +148,7 @@ export function PlainInput({
title={obscured ? `Show ${label}` : `Obscure ${label}`} title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs" size="xs"
className="mr-0.5 group/obscure !h-auto my-0.5" className="mr-0.5 group/obscure !h-auto my-0.5"
iconClassName="text-text-subtle group-hover/obscure:text" iconClassName="group-hover/obscure:text"
iconSize="sm" iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'} icon={obscured ? 'eye' : 'eye_closed'}
onClick={() => setObscured((o) => !o)} onClick={() => setObscured((o) => !o)}

View File

@@ -45,7 +45,7 @@ export function SegmentedControl<T extends string>({ value, onChange, options, n
<IconButton <IconButton
size="xs" size="xs"
variant="solid" variant="solid"
color={isActive ? "secondary" : "default"} color={isActive ? "secondary" : undefined}
role="radio" role="radio"
event={{ id: name, value: String(o.value) }} event={{ id: name, value: String(o.value) }}
tabIndex={isSelected ? 0 : -1} tabIndex={isSelected ? 0 : -1}

View File

@@ -0,0 +1,64 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React from 'react';
export function Table({ children }: { children: ReactNode }) {
return (
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
{children}
</table>
);
}
export function TableBody({ children }: { children: ReactNode }) {
return <tbody className="divide-y divide-surface-highlight">{children}</tbody>;
}
export function TableHead({ children }: { children: ReactNode }) {
return <thead>{children}</thead>;
}
export function TableRow({ children }: { children: ReactNode }) {
return <tr>{children}</tr>;
}
export function TableCell({ children, className }: { children: ReactNode; className?: string }) {
return (
<td
className={classNames(
className,
'py-2 [&:not(:first-child)]:pl-4 text-left w-0 whitespace-nowrap',
)}
>
{children}
</td>
);
}
export function TruncatedWideTableCell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<TableCell className={classNames(className, 'w-full relative')}>
<div className="absolute inset-0 py-2 truncate">{children}</div>
</TableCell>
);
}
export function TableHeaderCell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<th className={classNames(className, 'py-2 [&:not(:first-child)]:pl-4 text-left w-0')}>
{children}
</th>
);
}

View File

@@ -121,7 +121,7 @@ export function Tabs({
<Icon <Icon
size="sm" size="sm"
icon="chevron_down" icon="chevron_down"
className={classNames('ml-1', isActive ? 'text-text-subtle' : 'opacity-50')} className={classNames('ml-1', !isActive && 'opacity-50')}
/> />
</button> </button>
</RadioDropdown> </RadioDropdown>

View File

@@ -55,14 +55,14 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
<div <div
className={classNames( className={classNames(
`x-theme-toast x-theme-toast--${color}`, `x-theme-toast x-theme-toast--${color}`,
'pointer-events-auto overflow-hidden break-all', 'pointer-events-auto overflow-hidden',
'relative pointer-events-auto bg-surface text-text rounded-lg', 'relative pointer-events-auto bg-surface text-text rounded-lg',
'border border-border shadow-lg w-[25rem]', 'border border-border shadow-lg w-[25rem]',
'grid grid-cols-[1fr_auto]', 'grid grid-cols-[1fr_auto]',
)} )}
> >
<div className="px-3 py-3 flex items-start gap-2 w-full"> <div className="px-3 py-3 flex items-start gap-2 w-full">
{toastIcon && <Icon icon={toastIcon} className="mt-1 text-text-subtle" />} {toastIcon && <Icon icon={toastIcon} color={color} className="mt-1" />}
<VStack space={2} className="w-full"> <VStack space={2} className="w-full">
<div>{children}</div> <div>{children}</div>
{action?.({ hide: onClose })} {action?.({ hide: onClose })}

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { Button } from '../core/Button';
import { Select } from '../core/Select';
import { HStack, VStack } from '../core/Stacks';
interface Props {
branches: string[];
onCancel: () => void;
onSelect: (branch: string) => void;
selectText: string;
}
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
const [branch, setBranch] = useState<string>('__NONE__');
return (
<VStack
className="mb-4"
as="form"
space={4}
onSubmit={(e) => {
e.preventDefault();
onSelect(branch);
}}
>
<Select
name="branch"
hideLabel
label="Branch"
value={branch}
options={branches.map((b) => ({ label: b, value: b }))}
onChange={setBranch}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
Cancel
</Button>
<Button type="submit" color="primary">
{selectText}
</Button>
</HStack>
</VStack>
);
}

View File

@@ -0,0 +1,40 @@
import type { GitCommit } from '@yaakapp-internal/git';
import { formatDistanceToNowStrict } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from '../core/Table';
interface Props {
log: GitCommit[];
}
export function HistoryDialog({ log }: Props) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Message</TableHeaderCell>
<TableHeaderCell>Author</TableHeaderCell>
<TableHeaderCell>When</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{log.map((l, i) => (
<TableRow key={i}>
<TruncatedWideTableCell>{l.message}</TruncatedWideTableCell>
<TableCell className="font-bold">{l.author.name ?? 'Unknown'}</TableCell>
<TableCell className="text-text-subtle">
<span title={l.when}>{formatDistanceToNowStrict(l.when)} ago</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -153,7 +153,7 @@ function EventRow({
'text-text-subtle hover:text', 'text-text-subtle hover:text',
)} )}
> >
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" /> <Icon color="info" title="Server Message" icon="arrow_big_down_dash" />
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} /> <EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div> <div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
</button> </button>

View File

@@ -27,6 +27,7 @@ import { ContextMenu } from '../core/Dropdown';
import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms'; import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms';
import type { SidebarItemProps } from './SidebarItem'; import type { SidebarItemProps } from './SidebarItem';
import { SidebarItems } from './SidebarItems'; import { SidebarItems } from './SidebarItems';
import { GitDropdown } from '../GitDropdown';
interface Props { interface Props {
className?: string; className?: string;
@@ -378,6 +379,7 @@ export function Sidebar({ className }: Props) {
handleDragStart={handleDragStart} handleDragStart={handleDragStart}
/> />
</div> </div>
<GitDropdown />
</aside> </aside>
); );
} }

View File

@@ -32,9 +32,10 @@ export function SidebarActions() {
size="sm" size="sm"
title="Show sidebar" title="Show sidebar"
icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'} icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'}
iconColor="secondary"
/> />
<CreateDropdown hotKeyAction="http_request.create"> <CreateDropdown hotKeyAction="http_request.create">
<IconButton size="sm" icon="plus_circle" title="Add Resource" /> <IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
</CreateDropdown> </CreateDropdown>
</HStack> </HStack>
); );

View File

@@ -267,8 +267,8 @@ export const SidebarItem = memo(function SidebarItem({
<Icon <Icon
size="sm" size="sm"
icon="chevron_right" icon="chevron_right"
color="secondary"
className={classNames( className={classNames(
'text-text-subtlest',
'transition-transform', 'transition-transform',
!collapsed && 'transform rotate-90', !collapsed && 'transform rotate-90',
)} )}

View File

@@ -44,7 +44,7 @@ export const SidebarItems = memo(function SidebarItems({
aria-orientation="vertical" aria-orientation="vertical"
dir="ltr" dir="ltr"
className={classNames( className={classNames(
tree.depth > 0 && 'border-l border-border-subtle', tree.depth > 0 && 'border-l border-border',
tree.depth === 0 && 'ml-0', tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.2rem]', tree.depth >= 1 && 'ml-[1.2rem]',
)} )}

View File

@@ -1,7 +1,7 @@
import type { Workspace } from '@yaakapp-internal/models'; import type { Workspace } from '@yaakapp-internal/models';
import { InlineCode } from '../components/core/InlineCode'; import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import { showConfirm } from '../lib/confirm'; import { showConfirmDelete } from '../lib/confirm';
import { router } from '../lib/router'; import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { getActiveWorkspace } from './useActiveWorkspace'; import { getActiveWorkspace } from './useActiveWorkspace';
@@ -12,10 +12,9 @@ export function useDeleteActiveWorkspace() {
mutationKey: ['delete_workspace'], mutationKey: ['delete_workspace'],
mutationFn: async () => { mutationFn: async () => {
const workspace = getActiveWorkspace(); const workspace = getActiveWorkspace();
const confirmed = await showConfirm({ const confirmed = await showConfirmDelete({
id: 'delete-workspace', id: 'delete-workspace',
title: 'Delete Workspace', title: 'Delete Workspace',
variant: 'delete',
description: ( description: (
<> <>
Permanently delete <InlineCode>{workspace?.name}</InlineCode>? Permanently delete <InlineCode>{workspace?.name}</InlineCode>?

Some files were not shown because too many files have changed in this diff Show More