mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-18 21:57:09 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5919fae739 | |||
| a9cccb21b8 | |||
| 1c0435e3ff | |||
| a629a1fa79 | |||
| dde8d61b4b | |||
| 08a64b6938 | |||
| 560c4667e4 | |||
| 21f775741a | |||
| 44a331929f | |||
| 7670ab007f | |||
| be34dfe74a | |||
| e4103f1a4a | |||
| 7f4eedd630 | |||
| 49659a3da9 |
+7
-7
@@ -8,7 +8,7 @@ Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core R
|
|||||||
|
|
||||||
```
|
```
|
||||||
crates/ # Core crates - should NOT depend on Tauri
|
crates/ # Core crates - should NOT depend on Tauri
|
||||||
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
|
crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.)
|
||||||
crates-cli/ # CLI crate (yaak-cli)
|
crates-cli/ # CLI crate (yaak-cli)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ crates-cli/ # CLI crate (yaak-cli)
|
|||||||
|
|
||||||
### 1. Folder Restructure
|
### 1. Folder Restructure
|
||||||
|
|
||||||
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
|
- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
|
||||||
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
||||||
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
||||||
|
|
||||||
@@ -50,14 +50,14 @@ crates-cli/ # CLI crate (yaak-cli)
|
|||||||
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
||||||
4. Initialize managers in yaak-app's `.setup()` block
|
4. Initialize managers in yaak-app's `.setup()` block
|
||||||
5. Remove `tauri` from Cargo.toml dependencies
|
5. Remove `tauri` from Cargo.toml dependencies
|
||||||
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
|
6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission
|
||||||
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
|
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
|
||||||
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
|
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
|
||||||
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
|
- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits
|
||||||
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
||||||
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
||||||
|
|
||||||
@@ -79,5 +79,5 @@ e718a5f1 Refactor models_ext to use init_standalone from yaak-models
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
||||||
- Run `npm run client:dev` to test the Tauri app still works
|
- Run `npm run app-dev` to test the Tauri app still works
|
||||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
|
||||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
|
||||||
**/bindings/* linguist-generated=true
|
**/bindings/* linguist-generated=true
|
||||||
crates/yaak-templates/pkg/* linguist-generated=true
|
crates/yaak-templates/pkg/* linguist-generated=true
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ jobs:
|
|||||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Sign vendored binaries with hardened runtime and their specific entitlements
|
# Sign vendored binaries with hardened runtime and their specific entitlements
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
|
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true
|
||||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
|
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
@@ -155,8 +155,7 @@ jobs:
|
|||||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: true
|
prerelease: true
|
||||||
projectPath: ./crates-tauri/yaak-app-client
|
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"
|
||||||
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
|
|
||||||
|
|
||||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||||
- name: Build and upload machine-wide installer (Windows only)
|
- name: Build and upload machine-wide installer (Windows only)
|
||||||
@@ -172,9 +171,7 @@ jobs:
|
|||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||||
Push-Location crates-tauri/yaak-app-client
|
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
|
||||||
Pop-Location
|
|
||||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||||
$setupSig = "$($setup.FullName).sig"
|
$setupSig = "$($setup.FullName).sig"
|
||||||
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: vendored-assets
|
name: vendored-assets
|
||||||
path: |
|
path: |
|
||||||
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
|
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
|
||||||
crates-tauri/yaak-app-client/vendored/plugins
|
crates-tauri/yaak-app/vendored/plugins
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build-binaries:
|
build-binaries:
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: vendored-assets
|
name: vendored-assets
|
||||||
path: crates-tauri/yaak-app-client/vendored
|
path: crates-tauri/yaak-app/vendored
|
||||||
|
|
||||||
- name: Set CLI build version
|
- name: Set CLI build version
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
+1
-2
@@ -39,8 +39,7 @@ codebook.toml
|
|||||||
target
|
target
|
||||||
|
|
||||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
crates-tauri/yaak-app/tauri.worktree.conf.json
|
||||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
|
||||||
|
|
||||||
# Tauri auto-generated permission files
|
# Tauri auto-generated permission files
|
||||||
**/permissions/autogenerated
|
**/permissions/autogenerated
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
**/bindings/**
|
**/bindings/**
|
||||||
**/routeTree.gen.ts
|
|
||||||
crates/yaak-templates/pkg/**
|
crates/yaak-templates/pkg/**
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"ignorePatterns": [
|
|
||||||
"**/bindings/**",
|
|
||||||
"crates/yaak-templates/pkg/**",
|
|
||||||
"apps/yaak-client/routeTree.gen.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
vp lint
|
vp lint
|
||||||
vp staged
|
|
||||||
|
|||||||
Generated
+643
-566
File diff suppressed because it is too large
Load Diff
+5
-22
@@ -2,9 +2,6 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/yaak",
|
"crates/yaak",
|
||||||
# Common/foundation crates
|
|
||||||
"crates/common/yaak-database",
|
|
||||||
"crates/common/yaak-rpc",
|
|
||||||
# Shared crates (no Tauri dependency)
|
# Shared crates (no Tauri dependency)
|
||||||
"crates/yaak-core",
|
"crates/yaak-core",
|
||||||
"crates/yaak-common",
|
"crates/yaak-common",
|
||||||
@@ -20,19 +17,14 @@ members = [
|
|||||||
"crates/yaak-tls",
|
"crates/yaak-tls",
|
||||||
"crates/yaak-ws",
|
"crates/yaak-ws",
|
||||||
"crates/yaak-api",
|
"crates/yaak-api",
|
||||||
"crates/yaak-proxy",
|
|
||||||
# Proxy-specific crates
|
|
||||||
"crates-proxy/yaak-proxy-lib",
|
|
||||||
# CLI crates
|
# CLI crates
|
||||||
"crates-cli/yaak-cli",
|
"crates-cli/yaak-cli",
|
||||||
# Tauri-specific crates
|
# Tauri-specific crates
|
||||||
"crates-tauri/yaak-app-client",
|
"crates-tauri/yaak-app",
|
||||||
"crates-tauri/yaak-app-proxy",
|
|
||||||
"crates-tauri/yaak-fonts",
|
"crates-tauri/yaak-fonts",
|
||||||
"crates-tauri/yaak-license",
|
"crates-tauri/yaak-license",
|
||||||
"crates-tauri/yaak-mac-window",
|
"crates-tauri/yaak-mac-window",
|
||||||
"crates-tauri/yaak-tauri-utils",
|
"crates-tauri/yaak-tauri-utils",
|
||||||
"crates-tauri/yaak-window",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -47,18 +39,14 @@ schemars = { version = "0.8.22", features = ["chrono"] }
|
|||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
tauri = "2.11.1"
|
tauri = "2.9.5"
|
||||||
tauri-plugin = "2.6.1"
|
tauri-plugin = "2.5.2"
|
||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.4.2"
|
||||||
tauri-plugin-shell = "2.3.5"
|
tauri-plugin-shell = "2.3.3"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
ts-rs = "11.1.0"
|
ts-rs = "11.1.0"
|
||||||
|
|
||||||
# Internal crates - common/foundation
|
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
|
||||||
|
|
||||||
# Internal crates - shared
|
# Internal crates - shared
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
yaak-core = { path = "crates/yaak-core" }
|
||||||
yaak = { path = "crates/yaak" }
|
yaak = { path = "crates/yaak" }
|
||||||
@@ -75,17 +63,12 @@ yaak-templates = { path = "crates/yaak-templates" }
|
|||||||
yaak-tls = { path = "crates/yaak-tls" }
|
yaak-tls = { path = "crates/yaak-tls" }
|
||||||
yaak-ws = { path = "crates/yaak-ws" }
|
yaak-ws = { path = "crates/yaak-ws" }
|
||||||
yaak-api = { path = "crates/yaak-api" }
|
yaak-api = { path = "crates/yaak-api" }
|
||||||
yaak-proxy = { path = "crates/yaak-proxy" }
|
|
||||||
|
|
||||||
# Internal crates - proxy
|
|
||||||
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
|
||||||
|
|
||||||
# Internal crates - Tauri-specific
|
# Internal crates - Tauri-specific
|
||||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = false
|
strip = false
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
||||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
|
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,727 +0,0 @@
|
|||||||
import type { Cookie } from "@yaakapp-internal/models";
|
|
||||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { formatDate } from "date-fns/format";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
type ComponentProps,
|
|
||||||
type CSSProperties,
|
|
||||||
type FormEvent,
|
|
||||||
type ReactNode,
|
|
||||||
type RefObject,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { cookieDomain } from "../lib/model_util";
|
|
||||||
import {
|
|
||||||
Icon,
|
|
||||||
SplitLayout,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
TruncatedWideTableCell,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { EventDetailHeader } from "./core/EventViewer";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { Select } from "./core/Select";
|
|
||||||
import { showAlert } from "../lib/alert";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
cookieJarId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
|
||||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
|
||||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
|
||||||
const [filter, setFilter] = useState("");
|
|
||||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
|
||||||
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
|
|
||||||
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
|
|
||||||
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
|
|
||||||
const [draftExpiresInput, setDraftExpiresInput] = useState("");
|
|
||||||
const editorFormRef = useRef<HTMLFormElement>(null);
|
|
||||||
const filteredCookies = useMemo(() => {
|
|
||||||
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
|
|
||||||
}, [cookieJar?.cookies, filter]);
|
|
||||||
const selectedCookie = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedCookieKey == null
|
|
||||||
? null
|
|
||||||
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
|
|
||||||
[filteredCookies, selectedCookieKey],
|
|
||||||
);
|
|
||||||
const detailCookie = draftCookie ?? selectedCookie;
|
|
||||||
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
|
|
||||||
const isEditingCookie = draftCookie != null;
|
|
||||||
|
|
||||||
const handleAddCookie = () => {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
setEditingCookieKey(NEW_COOKIE_KEY);
|
|
||||||
setDraftCookie(newCookieDraft());
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditCookie = () => {
|
|
||||||
if (selectedCookie == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingCookieKey(cookieKey(selectedCookie));
|
|
||||||
setDraftCookie(selectedCookie);
|
|
||||||
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
if (isCreatingCookie) {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
}
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseDetails = () => {
|
|
||||||
if (isEditingCookie) {
|
|
||||||
handleCancelEdit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (cookieJar == null || draftCookie == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextCookie = normalizeCookie(draftCookie);
|
|
||||||
if (nextCookie.expires !== "SessionEnd") {
|
|
||||||
const expires = cookieExpiresFromInput(draftExpiresInput);
|
|
||||||
if (expires == null) {
|
|
||||||
showAlert({
|
|
||||||
id: "invalid-cookie-expires",
|
|
||||||
title: "Invalid Cookie",
|
|
||||||
body: "Cookie expiration must be a valid date.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextCookie = { ...nextCookie, expires };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextCookieKey = cookieKey(nextCookie);
|
|
||||||
const nextCookies = cookieJar.cookies.filter((cookie) => {
|
|
||||||
const key = cookieKey(cookie);
|
|
||||||
if (editingCookieKey != null && key === editingCookieKey) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return key !== nextCookieKey;
|
|
||||||
});
|
|
||||||
|
|
||||||
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
|
||||||
setSelectedCookieKey(nextCookieKey);
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cookieJar == null) {
|
|
||||||
return <div>No cookie jar selected</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
|
||||||
<PlainInput
|
|
||||||
name="cookie-filter"
|
|
||||||
label="Filter cookies"
|
|
||||||
hideLabel
|
|
||||||
placeholder="Filter cookies"
|
|
||||||
defaultValue={filter}
|
|
||||||
forceUpdateKey={filterUpdateKey}
|
|
||||||
onChange={setFilter}
|
|
||||||
rightSlot={
|
|
||||||
filter.length > 0 && (
|
|
||||||
<IconButton
|
|
||||||
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
|
||||||
icon="x"
|
|
||||||
title="Clear filter"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter("");
|
|
||||||
setFilterUpdateKey((key) => key + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
|
|
||||||
</div>
|
|
||||||
{cookieJar.cookies.length === 0 && detailCookie == null ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
Cookies will appear when a response includes a Set-Cookie header.
|
|
||||||
</EmptyStateText>
|
|
||||||
) : filteredCookies.length === 0 && detailCookie == null ? (
|
|
||||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<SplitLayout
|
|
||||||
layout="vertical"
|
|
||||||
storageKey="cookie-dialog-details"
|
|
||||||
defaultRatio={0.5}
|
|
||||||
className="-mx-2"
|
|
||||||
minHeightPx={10}
|
|
||||||
firstSlot={({ style }) =>
|
|
||||||
filteredCookies.length === 0 ? (
|
|
||||||
<div style={style}>
|
|
||||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table scrollable style={style} className="pr-0.5">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Value</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Domain</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Path</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Expires</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Size</TableHeaderCell>
|
|
||||||
<TableHeaderCell>HTTP Only</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Secure</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Same Site</TableHeaderCell>
|
|
||||||
<TableHeaderCell>
|
|
||||||
<IconButton
|
|
||||||
icon="list_x"
|
|
||||||
size="sm"
|
|
||||||
className="text-text-subtle"
|
|
||||||
title="Clear all cookies"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
patchModel(cookieJar, { cookies: [] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
|
|
||||||
{filteredCookies.map((c: Cookie) => {
|
|
||||||
const key = cookieKey(c);
|
|
||||||
const isSelected = key === selectedCookieKey;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={key}
|
|
||||||
className={classNames(
|
|
||||||
"group/tr cursor-default",
|
|
||||||
isSelected && "[&_td]:bg-surface-highlight",
|
|
||||||
!isSelected && "hover:[&_td]:bg-surface-hover",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCookieKey(key);
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
|
|
||||||
{c.name}
|
|
||||||
</TableCell>
|
|
||||||
<TruncatedWideTableCell className="min-w-[10rem]">
|
|
||||||
{c.value}
|
|
||||||
</TruncatedWideTableCell>
|
|
||||||
<TableCell>{cookieDomain(c)}</TableCell>
|
|
||||||
<TableCell>{c.path}</TableCell>
|
|
||||||
<TableCell>{cookieExpires(c)}</TableCell>
|
|
||||||
<TableCell>{cookieSize(c)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Icon
|
|
||||||
icon={c.httpOnly ? "check" : "x"}
|
|
||||||
className={classNames(!c.httpOnly && "opacity-10")}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Icon
|
|
||||||
icon={c.secure ? "check" : "x"}
|
|
||||||
className={classNames(!c.secure && "opacity-10")}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{c.sameSite}</TableCell>
|
|
||||||
<TableCell className="rounded-r pr-2">
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Delete"
|
|
||||||
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (isSelected) {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
}
|
|
||||||
if (editingCookieKey === key) {
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
}
|
|
||||||
patchModel(cookieJar, {
|
|
||||||
cookies: cookieJar.cookies.filter(
|
|
||||||
(c2: Cookie) => cookieKey(c2) !== key,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
secondSlot={
|
|
||||||
detailCookie == null
|
|
||||||
? null
|
|
||||||
: ({ style }) => (
|
|
||||||
<CookieDetailsPane
|
|
||||||
formRef={editorFormRef}
|
|
||||||
isEditing={isEditingCookie}
|
|
||||||
onSubmit={handleSaveCookie}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<EventDetailHeader
|
|
||||||
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
|
||||||
copyText={isEditingCookie ? undefined : detailCookie.value}
|
|
||||||
actions={
|
|
||||||
isEditingCookie
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "save",
|
|
||||||
label: isCreatingCookie ? "Create" : "Save",
|
|
||||||
onClick: () => editorFormRef.current?.requestSubmit(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cancel",
|
|
||||||
label: "Cancel",
|
|
||||||
onClick: handleCancelEdit,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: "edit",
|
|
||||||
label: "Edit",
|
|
||||||
onClick: handleEditCookie,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
onClose={handleCloseDetails}
|
|
||||||
/>
|
|
||||||
{isEditingCookie ? (
|
|
||||||
<CookieEditor
|
|
||||||
cookie={detailCookie}
|
|
||||||
expiresInputValue={draftExpiresInput}
|
|
||||||
onChange={setDraftCookie}
|
|
||||||
onExpiresInputChange={setDraftExpiresInput}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CookieDetails cookie={detailCookie} />
|
|
||||||
)}
|
|
||||||
</CookieDetailsPane>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function CookieDetailsPane({
|
|
||||||
children,
|
|
||||||
formRef,
|
|
||||||
isEditing,
|
|
||||||
onSubmit,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
formRef: RefObject<HTMLFormElement | null>;
|
|
||||||
isEditing: boolean;
|
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
||||||
style: CSSProperties;
|
|
||||||
}) {
|
|
||||||
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
|
|
||||||
{children}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={style} className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CookieDialog.show = (cookieJarId: string | null) => {
|
|
||||||
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
|
|
||||||
if (cookieJar == null) {
|
|
||||||
showAlert({
|
|
||||||
id: "invalid-jar",
|
|
||||||
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
|
|
||||||
title: "Invalid Cookie Jar",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "cookies",
|
|
||||||
title: `${cookieJar.name} Cookies`,
|
|
||||||
size: "full",
|
|
||||||
render: () => <CookieDialog cookieJarId={cookieJarId} />,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function CookieDetails({ cookie }: { cookie: Cookie }) {
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
<KeyValueRows selectable>
|
|
||||||
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
|
|
||||||
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
|
|
||||||
{cookie.sameSite && (
|
|
||||||
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
|
|
||||||
)}
|
|
||||||
</KeyValueRows>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieEditor({
|
|
||||||
cookie,
|
|
||||||
expiresInputValue,
|
|
||||||
onChange,
|
|
||||||
onExpiresInputChange,
|
|
||||||
}: {
|
|
||||||
cookie: Cookie;
|
|
||||||
expiresInputValue: string;
|
|
||||||
onChange: (cookie: Cookie) => void;
|
|
||||||
onExpiresInputChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const sessionCookie = cookie.expires === "SessionEnd";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
<KeyValueRows>
|
|
||||||
<CookieKeyValueRow align="middle" label="Name">
|
|
||||||
<CookieTextInput
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
|
||||||
value={cookie.name}
|
|
||||||
onChange={(name) => onChange({ ...cookie, name })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Value">
|
|
||||||
<CookieTextarea
|
|
||||||
value={cookie.value}
|
|
||||||
onChange={(value) => onChange({ ...cookie, value })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Domain">
|
|
||||||
<CookieTextInput
|
|
||||||
required
|
|
||||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
|
||||||
value={cookieDomainInputValue(cookie)}
|
|
||||||
placeholder="example.com"
|
|
||||||
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Path">
|
|
||||||
<CookieTextInput
|
|
||||||
value={cookie.path}
|
|
||||||
placeholder="/"
|
|
||||||
onChange={(path) => onChange({ ...cookie, path })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Expires">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={sessionCookie}
|
|
||||||
title="Session cookie"
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
onChange({ ...cookie, expires: "SessionEnd" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresInput =
|
|
||||||
cookieExpiresFromInput(expiresInputValue) == null
|
|
||||||
? defaultCookieExpiresInputValue()
|
|
||||||
: expiresInputValue;
|
|
||||||
|
|
||||||
onExpiresInputChange(expiresInput);
|
|
||||||
onChange({
|
|
||||||
...cookie,
|
|
||||||
expires: cookieExpiresFromInput(expiresInput)!,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CookieTextInput
|
|
||||||
value={sessionCookie ? "" : expiresInputValue}
|
|
||||||
disabled={sessionCookie}
|
|
||||||
onChange={(value) => {
|
|
||||||
onExpiresInputChange(value);
|
|
||||||
|
|
||||||
const expires = cookieExpiresFromInput(value);
|
|
||||||
if (expires != null) {
|
|
||||||
onChange({ ...cookie, expires });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="HTTP Only">
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title="HTTP Only"
|
|
||||||
checked={cookie.httpOnly}
|
|
||||||
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Secure">
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title="Secure"
|
|
||||||
checked={cookie.secure}
|
|
||||||
onChange={(secure) => onChange({ ...cookie, secure })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Same Site">
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name="cookie-same-site"
|
|
||||||
label="Same Site"
|
|
||||||
value={cookie.sameSite ?? ""}
|
|
||||||
size="xs"
|
|
||||||
className="w-full"
|
|
||||||
options={[
|
|
||||||
{ label: "n/a", value: "" },
|
|
||||||
{ label: "Lax", value: "Lax" },
|
|
||||||
{ label: "Strict", value: "Strict" },
|
|
||||||
{ label: "None", value: "None" },
|
|
||||||
]}
|
|
||||||
onChange={(sameSite) =>
|
|
||||||
onChange({
|
|
||||||
...cookie,
|
|
||||||
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
|
|
||||||
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieTextInput({
|
|
||||||
autoFocus,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
pattern,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
autoFocus?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
pattern?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
className={cookieInputClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
pattern={pattern}
|
|
||||||
placeholder={placeholder}
|
|
||||||
required={required}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEW_COOKIE_KEY = "__new-cookie__";
|
|
||||||
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
|
|
||||||
const cookieInputClassName = classNames(
|
|
||||||
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
|
|
||||||
"border border-border-subtle outline-none",
|
|
||||||
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
|
|
||||||
"focus:border-border-focus invalid:border-danger",
|
|
||||||
"disabled:opacity-disabled disabled:border-dotted",
|
|
||||||
);
|
|
||||||
|
|
||||||
function cookieSize(cookie: Cookie) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newCookieDraft(): Cookie {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
value: "",
|
|
||||||
domain: "NotPresent",
|
|
||||||
expires: "SessionEnd",
|
|
||||||
path: "/",
|
|
||||||
secure: false,
|
|
||||||
httpOnly: false,
|
|
||||||
sameSite: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCookie(cookie: Cookie): Cookie {
|
|
||||||
return {
|
|
||||||
...cookie,
|
|
||||||
domain: normalizeCookieDomain(cookie.domain),
|
|
||||||
name: cookie.name.trim(),
|
|
||||||
path: cookie.path.trim() || "/",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
|
|
||||||
if (domain === "NotPresent" || domain === "Empty") {
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("Suffix" in domain) {
|
|
||||||
return { Suffix: domain.Suffix.trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { HostOnly: domain.HostOnly.trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieDomainInputValue(cookie: Cookie) {
|
|
||||||
const domain = cookieDomain(cookie);
|
|
||||||
return domain === "n/a" ? "" : domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
|
|
||||||
const trimmedDomain = domain.trim();
|
|
||||||
if (trimmedDomain.length === 0) {
|
|
||||||
return { ...cookie, domain: "NotPresent" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
|
|
||||||
return { ...cookie, domain: { Suffix: trimmedDomain } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...cookie, domain: { HostOnly: trimmedDomain } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpires(cookie: Cookie) {
|
|
||||||
if (cookie.expires === "SessionEnd") {
|
|
||||||
return "Session";
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
|
||||||
if (!Number.isFinite(expiresSeconds)) {
|
|
||||||
return cookie.expires.AtUtc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(expiresSeconds * 1000);
|
|
||||||
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpiresInputValue(cookie: Cookie) {
|
|
||||||
if (cookie.expires === "SessionEnd") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
|
||||||
if (!Number.isFinite(expiresSeconds)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(expiresSeconds * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultCookieExpiresInputValue() {
|
|
||||||
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
if (!Number.isFinite(time)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { AtUtc: `${Math.floor(time / 1000)}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieMatchesFilter(cookie: Cookie, filter: string) {
|
|
||||||
const query = filter.trim().toLowerCase();
|
|
||||||
if (query.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
|
|
||||||
value.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieKey(cookie: Cookie) {
|
|
||||||
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieDomainKey(domain: Cookie["domain"]) {
|
|
||||||
if (typeof domain !== "string" && "HostOnly" in domain) {
|
|
||||||
return `HostOnly:${domain.HostOnly}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof domain !== "string" && "Suffix" in domain) {
|
|
||||||
return `Suffix:${domain.Suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import type {
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
InheritedBoolSetting,
|
|
||||||
InheritedIntSetting,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
|
||||||
import {
|
|
||||||
modelSupportsSetting,
|
|
||||||
type RequestSettingDefinition,
|
|
||||||
SETTING_FOLLOW_REDIRECTS,
|
|
||||||
SETTING_REQUEST_TIMEOUT,
|
|
||||||
SETTING_SEND_COOKIES,
|
|
||||||
SETTING_STORE_COOKIES,
|
|
||||||
SETTING_VALIDATE_CERTIFICATES,
|
|
||||||
} from "../lib/requestSettings";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import {
|
|
||||||
SettingOverrideRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowNumber,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "./core/SettingRow";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
showSectionTitles?: boolean;
|
|
||||||
model: ModelWithSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
|
||||||
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
|
||||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
|
||||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
|
||||||
type IntegerSetting = number | InheritedIntSetting;
|
|
||||||
type CookieSettingsPatch = {
|
|
||||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
|
||||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
|
||||||
};
|
|
||||||
type HttpSettingsPatch = {
|
|
||||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
|
||||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
|
||||||
};
|
|
||||||
type TlsSettingsPatch = {
|
|
||||||
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
|
||||||
const ancestors = useModelAncestors(model);
|
|
||||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
|
||||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
|
||||||
const supportsTlsSettings = modelSupportsTlsSettings(model);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
{supportsTlsSettings && (
|
|
||||||
<SettingsSection title={showSectionTitles ? "Requests" : null}>
|
|
||||||
{supportsHttpSettings && (
|
|
||||||
<IntegerSettingRow
|
|
||||||
settingDefinition={SETTING_REQUEST_TIMEOUT}
|
|
||||||
setting={model.settingRequestTimeout}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_REQUEST_TIMEOUT.modelKey,
|
|
||||||
model.settingRequestTimeout,
|
|
||||||
)}
|
|
||||||
onChange={(settingRequestTimeout) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingRequestTimeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<BooleanSettingRow
|
|
||||||
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
|
|
||||||
setting={model.settingValidateCertificates}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_VALIDATE_CERTIFICATES.modelKey,
|
|
||||||
model.settingValidateCertificates,
|
|
||||||
)}
|
|
||||||
onChange={(settingValidateCertificates) =>
|
|
||||||
patchTlsSettings(model, {
|
|
||||||
settingValidateCertificates,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{supportsHttpSettings && (
|
|
||||||
<BooleanSettingRow
|
|
||||||
settingDefinition={SETTING_FOLLOW_REDIRECTS}
|
|
||||||
setting={model.settingFollowRedirects}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_FOLLOW_REDIRECTS.modelKey,
|
|
||||||
model.settingFollowRedirects,
|
|
||||||
)}
|
|
||||||
onChange={(settingFollowRedirects) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingFollowRedirects,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
{supportsCookieSettings && (
|
|
||||||
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
|
||||||
<BooleanSettingRow
|
|
||||||
settingDefinition={SETTING_SEND_COOKIES}
|
|
||||||
setting={model.settingSendCookies}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_SEND_COOKIES.modelKey,
|
|
||||||
model.settingSendCookies,
|
|
||||||
)}
|
|
||||||
onChange={(settingSendCookies) =>
|
|
||||||
patchCookieSettings(model, {
|
|
||||||
settingSendCookies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BooleanSettingRow
|
|
||||||
settingDefinition={SETTING_STORE_COOKIES}
|
|
||||||
setting={model.settingStoreCookies}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_STORE_COOKIES.modelKey,
|
|
||||||
model.settingStoreCookies,
|
|
||||||
)}
|
|
||||||
onChange={(settingStoreCookies) =>
|
|
||||||
patchCookieSettings(model, {
|
|
||||||
settingStoreCookies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
</SettingsList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
|
||||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
|
||||||
|
|
||||||
if (modelSupportsCookieSettings(model)) {
|
|
||||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.push(model.settingValidateCertificates);
|
|
||||||
|
|
||||||
if (modelSupportsHttpSettings(model)) {
|
|
||||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
|
||||||
.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
|
||||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
|
||||||
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
if (model.model === "websocket_request")
|
|
||||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
|
||||||
throw new Error("Unsupported cookie settings model");
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
|
||||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
|
||||||
return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
|
||||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
|
||||||
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
if (model.model === "websocket_request")
|
|
||||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
|
||||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BooleanSettingRow({
|
|
||||||
inheritedValue,
|
|
||||||
setting,
|
|
||||||
settingDefinition,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
inheritedValue: boolean;
|
|
||||||
setting: BooleanSetting;
|
|
||||||
settingDefinition: RequestSettingDefinition;
|
|
||||||
onChange: (setting: BooleanSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={value}
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size="md"
|
|
||||||
title={settingDefinition.title}
|
|
||||||
checked={value}
|
|
||||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IntegerSettingRow({
|
|
||||||
inheritedValue,
|
|
||||||
setting,
|
|
||||||
settingDefinition,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
inheritedValue: number;
|
|
||||||
setting: IntegerSetting;
|
|
||||||
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
|
|
||||||
onChange: (setting: IntegerSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRowNumber
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
value={value}
|
|
||||||
placeholder={`${settingDefinition.defaultValue}`}
|
|
||||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<PlainInput
|
|
||||||
hideLabel
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
size="sm"
|
|
||||||
type="number"
|
|
||||||
placeholder={`${settingDefinition.defaultValue}`}
|
|
||||||
defaultValue={`${value}`}
|
|
||||||
containerClassName="!w-48"
|
|
||||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
|
||||||
onChange={(value) =>
|
|
||||||
onChange({
|
|
||||||
...setting,
|
|
||||||
enabled: true,
|
|
||||||
value: Number.parseInt(value, 10) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInheritedSetting<T>(
|
|
||||||
setting: T | { enabled?: boolean; value: T },
|
|
||||||
): setting is { enabled?: boolean; value: T } {
|
|
||||||
return typeof setting === "object" && setting != null && "value" in setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: "settingRequestTimeout",
|
|
||||||
fallback: IntegerSetting,
|
|
||||||
): number;
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: BooleanWorkspaceSettingKey,
|
|
||||||
fallback: BooleanSetting,
|
|
||||||
): boolean;
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: keyof WorkspaceSettings,
|
|
||||||
fallback: BooleanSetting | IntegerSetting,
|
|
||||||
) {
|
|
||||||
for (const ancestor of ancestors) {
|
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
|
||||||
if (isInheritedSetting(setting)) {
|
|
||||||
if (setting.enabled === true) {
|
|
||||||
return setting.value;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceSettings = Pick<
|
|
||||||
Workspace,
|
|
||||||
| "settingFollowRedirects"
|
|
||||||
| "settingRequestTimeout"
|
|
||||||
| "settingSendCookies"
|
|
||||||
| "settingStoreCookies"
|
|
||||||
| "settingValidateCertificates"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
|
||||||
import { appInfo } from "../../lib/appInfo";
|
|
||||||
import {
|
|
||||||
SETTING_FOLLOW_REDIRECTS,
|
|
||||||
SETTING_REQUEST_TIMEOUT,
|
|
||||||
SETTING_SEND_COOKIES,
|
|
||||||
SETTING_STORE_COOKIES,
|
|
||||||
SETTING_VALIDATE_CERTIFICATES,
|
|
||||||
} from "../../lib/requestSettings";
|
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import {
|
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingRowNumber,
|
|
||||||
ModelSettingSelectControl,
|
|
||||||
SettingValue,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const checkForUpdates = useCheckForUpdates();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Heading>General</Heading>
|
|
||||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<CargoFeature feature="updater">
|
|
||||||
<SettingsSection title="Updates">
|
|
||||||
<SettingRow
|
|
||||||
title="Update Channel"
|
|
||||||
description="Choose whether Yaak should use stable releases or beta releases."
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
|
||||||
<ModelSettingSelectControl
|
|
||||||
model={settings}
|
|
||||||
modelKey="updateChannel"
|
|
||||||
label="Update Channel"
|
|
||||||
selectClassName="!w-full"
|
|
||||||
options={[
|
|
||||||
{ label: "Stable", value: "stable" },
|
|
||||||
{ label: "Beta", value: "beta" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
title="Check for updates"
|
|
||||||
icon="refresh"
|
|
||||||
spin={checkForUpdates.isPending}
|
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Update Behavior"
|
|
||||||
description="Choose whether updates are installed automatically or manually."
|
|
||||||
name="autoupdate"
|
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
|
||||||
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "auto" },
|
|
||||||
{ label: "Manual", value: "manual" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="autoDownloadUpdates"
|
|
||||||
title="Automatically download updates"
|
|
||||||
description="Download Yaak updates in the background so they are ready to install."
|
|
||||||
disabled={!settings.autoupdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="checkNotifications"
|
|
||||||
title="Check for notifications"
|
|
||||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRowBoolean
|
|
||||||
title="Send anonymous usage statistics"
|
|
||||||
description="Yaak is local-first and does not collect analytics or usage data."
|
|
||||||
disabled
|
|
||||||
checked={false}
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Workspace{" "}
|
|
||||||
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
|
|
||||||
{workspace.name}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ModelSettingRowNumber
|
|
||||||
model={workspace}
|
|
||||||
modelKey={SETTING_REQUEST_TIMEOUT.modelKey}
|
|
||||||
title={SETTING_REQUEST_TIMEOUT.title}
|
|
||||||
description={SETTING_REQUEST_TIMEOUT.description}
|
|
||||||
placeholder={`${SETTING_REQUEST_TIMEOUT.defaultValue}`}
|
|
||||||
required
|
|
||||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey={SETTING_VALIDATE_CERTIFICATES.modelKey}
|
|
||||||
title={SETTING_VALIDATE_CERTIFICATES.title}
|
|
||||||
description={SETTING_VALIDATE_CERTIFICATES.description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey={SETTING_FOLLOW_REDIRECTS.modelKey}
|
|
||||||
title={SETTING_FOLLOW_REDIRECTS.title}
|
|
||||||
description={SETTING_FOLLOW_REDIRECTS.description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey={SETTING_SEND_COOKIES.modelKey}
|
|
||||||
title={SETTING_SEND_COOKIES.title}
|
|
||||||
description={SETTING_SEND_COOKIES.description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey={SETTING_STORE_COOKIES.modelKey}
|
|
||||||
title={SETTING_STORE_COOKIES.title}
|
|
||||||
description={SETTING_STORE_COOKIES.description}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="App Info">
|
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
|
||||||
<SettingValue value={appInfo.version} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Data Directory"
|
|
||||||
description="Where Yaak stores application data."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appDataDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => revealItemInDir(appInfo.appDataDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Logs Directory"
|
|
||||||
description="Where Yaak writes application logs."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appLogDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => revealItemInDir(appInfo.appLogDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
|
||||||
import { useFonts } from "@yaakapp-internal/fonts";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
|
||||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { invokeCmd } from "../../lib/tauri";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import {
|
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingSelectControl,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
|
||||||
|
|
||||||
const fontSizeOptions = [
|
|
||||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
|
||||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
|
||||||
|
|
||||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
|
||||||
{ value: "default", label: "Default" },
|
|
||||||
{ value: "vim", label: "Vim" },
|
|
||||||
{ value: "vscode", label: "VSCode" },
|
|
||||||
{ value: "emacs", label: "Emacs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsInterface() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const fonts = useFonts();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Interface</Heading>
|
|
||||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Workspaces">
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Open workspace behavior"
|
|
||||||
description="Choose what happens when opening another workspace."
|
|
||||||
name="switchWorkspaceBehavior"
|
|
||||||
value={
|
|
||||||
settings.openWorkspaceNewWindow === true
|
|
||||||
? "new"
|
|
||||||
: settings.openWorkspaceNewWindow === false
|
|
||||||
? "current"
|
|
||||||
: "ask"
|
|
||||||
}
|
|
||||||
onChange={async (v) => {
|
|
||||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
|
||||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
|
||||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ label: "Always ask", value: "ask" },
|
|
||||||
{ label: "Open in current window", value: "current" },
|
|
||||||
{ label: "Open in new window", value: "new" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Fonts">
|
|
||||||
<SettingRow
|
|
||||||
title="Interface font"
|
|
||||||
description="Font used for Yaak interface controls."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="uiFont"
|
|
||||||
label="Interface font"
|
|
||||||
selectClassName="!w-72"
|
|
||||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { interfaceFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="interfaceFontSize"
|
|
||||||
label="Interface Font Size"
|
|
||||||
selectClassName="!w-20"
|
|
||||||
value={`${settings.interfaceFontSize}`}
|
|
||||||
defaultValue="14"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Editor font"
|
|
||||||
description="Font used in request and response editors."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFont"
|
|
||||||
label="Editor font"
|
|
||||||
selectClassName="!w-72"
|
|
||||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { editorFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFontSize"
|
|
||||||
label="Editor Font Size"
|
|
||||||
selectClassName="!w-20"
|
|
||||||
value={`${settings.editorFontSize}`}
|
|
||||||
defaultValue="12"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, {
|
|
||||||
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Editor">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorKeymap"
|
|
||||||
title="Editor keymap"
|
|
||||||
description="Keyboard shortcut preset used by text editors."
|
|
||||||
options={keymaps}
|
|
||||||
/>
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorSoftWrap"
|
|
||||||
title="Wrap editor lines"
|
|
||||||
description="Wrap long lines in request and response editors."
|
|
||||||
/>
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="coloredMethods"
|
|
||||||
title="Colorize request methods"
|
|
||||||
description="Use method-specific colors for HTTP request methods."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Window">
|
|
||||||
<NativeTitlebarSetting settings={settings} />
|
|
||||||
{type() !== "macos" && (
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="hideWindowControls"
|
|
||||||
title="Hide window controls"
|
|
||||||
description="Hide the close, maximize, and minimize controls on Windows or Linux."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<CargoFeature feature="license">
|
|
||||||
<LicenseSettings settings={settings} />
|
|
||||||
</CargoFeature>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|
||||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
title="Native title bar"
|
|
||||||
description="Use the operating system's standard title bar and window controls."
|
|
||||||
controlClassName="gap-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size="md"
|
|
||||||
checked={nativeTitlebar}
|
|
||||||
title="Native title bar"
|
|
||||||
onChange={setNativeTitlebar}
|
|
||||||
/>
|
|
||||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
onClick={async () => {
|
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
|
||||||
await invokeCmd("cmd_restart");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply and Restart
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
|
||||||
const license = useLicense();
|
|
||||||
if (license.check.data?.status !== "personal_use") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection title="License">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={settings.hideLicenseBadge}
|
|
||||||
title="Hide personal use badge"
|
|
||||||
description="Hide the personal-use badge from the interface."
|
|
||||||
onChange={async (hideLicenseBadge) => {
|
|
||||||
if (hideLicenseBadge) {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "hide-license-badge",
|
|
||||||
title: "Confirm Personal Use",
|
|
||||||
confirmText: "Confirm",
|
|
||||||
description: (
|
|
||||||
<VStack space={3}>
|
|
||||||
<p>Hey there 👋🏼</p>
|
|
||||||
<p>
|
|
||||||
Yaak is free for personal projects and learning.{" "}
|
|
||||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
|
||||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
|
||||||
</p>
|
|
||||||
</VStack>
|
|
||||||
),
|
|
||||||
requireTyping: "Personal Use",
|
|
||||||
color: "info",
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await patchModel(settings, { hideLicenseBadge });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingRowText,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
export function SettingsProxy() {
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const proxy = enabledProxyOrDefault(settings.proxy);
|
|
||||||
|
|
||||||
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: {
|
|
||||||
...proxy,
|
|
||||||
...patch,
|
|
||||||
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Proxy</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
|
||||||
traffic, or routing through specific infrastructure.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Proxy">
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Proxy"
|
|
||||||
description="Choose how Yaak should discover or use proxy settings."
|
|
||||||
name="proxy"
|
|
||||||
value={settings.proxy?.type ?? "automatic"}
|
|
||||||
onChange={async (v) => {
|
|
||||||
if (v === "automatic") {
|
|
||||||
await patchModel(settings, { proxy: undefined });
|
|
||||||
} else if (v === "enabled") {
|
|
||||||
await patchModel(settings, { proxy });
|
|
||||||
} else {
|
|
||||||
await patchModel(settings, { proxy: { type: "disabled" } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic proxy detection", value: "automatic" },
|
|
||||||
{ label: "Custom proxy configuration", value: "enabled" },
|
|
||||||
{ label: "No proxy", value: "disabled" },
|
|
||||||
]}
|
|
||||||
selectClassName="!w-64"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{settings.proxy?.type === "enabled" && (
|
|
||||||
<>
|
|
||||||
<SettingsSection title="Custom Proxy">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={!settings.proxy.disabled}
|
|
||||||
title="Enable proxy"
|
|
||||||
description="Temporarily disable the proxy without losing the configuration."
|
|
||||||
onChange={(enabled) => patchProxy({ disabled: !enabled })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttp"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for unencrypted HTTP traffic."
|
|
||||||
value={settings.proxy.http}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(http) => patchProxy({ http })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttps"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for HTTPS traffic."
|
|
||||||
value={settings.proxy.https}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(https) => patchProxy({ https })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyBypass"
|
|
||||||
title="Proxy Bypass"
|
|
||||||
description="Comma-separated list of hosts that should bypass the proxy."
|
|
||||||
value={settings.proxy.bypass}
|
|
||||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
|
||||||
inputWidthClassName="!w-96"
|
|
||||||
onChange={(bypass) => patchProxy({ bypass })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Authentication">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={settings.proxy.auth != null}
|
|
||||||
title="Enable authentication"
|
|
||||||
description="Send proxy credentials with proxied requests."
|
|
||||||
onChange={(enabled) =>
|
|
||||||
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.proxy.auth != null && (
|
|
||||||
<>
|
|
||||||
<SettingRowText
|
|
||||||
required
|
|
||||||
name="proxyUser"
|
|
||||||
title="User"
|
|
||||||
description="Username for proxy authentication."
|
|
||||||
value={settings.proxy.auth.user}
|
|
||||||
placeholder="myUser"
|
|
||||||
onChange={(user) =>
|
|
||||||
patchProxy({
|
|
||||||
auth: {
|
|
||||||
user,
|
|
||||||
password:
|
|
||||||
settings.proxy?.type === "enabled"
|
|
||||||
? (settings.proxy.auth?.password ?? "")
|
|
||||||
: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyPassword"
|
|
||||||
title="Password"
|
|
||||||
description="Password for proxy authentication."
|
|
||||||
value={settings.proxy.auth.password}
|
|
||||||
placeholder="s3cretPassw0rd"
|
|
||||||
type="password"
|
|
||||||
onChange={(password) =>
|
|
||||||
patchProxy({
|
|
||||||
auth: {
|
|
||||||
user:
|
|
||||||
settings.proxy?.type === "enabled"
|
|
||||||
? (settings.proxy.auth?.user ?? "")
|
|
||||||
: "",
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
|
|
||||||
|
|
||||||
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
|
|
||||||
if (proxy?.type === "enabled") return proxy;
|
|
||||||
|
|
||||||
return {
|
|
||||||
disabled: false,
|
|
||||||
type: "enabled",
|
|
||||||
http: "",
|
|
||||||
https: "",
|
|
||||||
auth: { user: "", password: "" },
|
|
||||||
bypass: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
|
|
||||||
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
|
|
||||||
import type { ButtonProps } from "../core/Button";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import type { SelectProps } from "../core/Select";
|
|
||||||
import {
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
|
||||||
|
|
||||||
const buttonColors: ButtonProps["color"][] = [
|
|
||||||
"primary",
|
|
||||||
"info",
|
|
||||||
"success",
|
|
||||||
"notice",
|
|
||||||
"warning",
|
|
||||||
"danger",
|
|
||||||
"secondary",
|
|
||||||
"default",
|
|
||||||
];
|
|
||||||
|
|
||||||
const icons: IconProps["icon"][] = [
|
|
||||||
"info",
|
|
||||||
"box",
|
|
||||||
"update",
|
|
||||||
"alert_triangle",
|
|
||||||
"arrow_big_right_dash",
|
|
||||||
"download",
|
|
||||||
"copy",
|
|
||||||
"magic_wand",
|
|
||||||
"settings",
|
|
||||||
"trash",
|
|
||||||
"sparkles",
|
|
||||||
"pencil",
|
|
||||||
"paste",
|
|
||||||
"search",
|
|
||||||
"send_horizontal",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsTheme() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const appearance = useResolvedAppearance();
|
|
||||||
const activeTheme = useResolvedTheme();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
|
||||||
.filter((theme) => !theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
|
||||||
.filter((theme) => theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Theme</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Make Yaak your own by selecting a theme, or{" "}
|
|
||||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
|
||||||
Create Your Own
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Theme">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="appearance"
|
|
||||||
title="Appearance"
|
|
||||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "system" },
|
|
||||||
{ label: "Light", value: "light" },
|
|
||||||
{ label: "Dark", value: "dark" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="lightTheme"
|
|
||||||
title="Light theme"
|
|
||||||
description="Theme used when Yaak is in light mode."
|
|
||||||
value={activeTheme.data.light.id}
|
|
||||||
options={lightThemes}
|
|
||||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="darkTheme"
|
|
||||||
title="Dark theme"
|
|
||||||
description="Theme used when Yaak is in dark mode."
|
|
||||||
value={activeTheme.data.dark.id}
|
|
||||||
options={darkThemes}
|
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Preview">
|
|
||||||
<VStack
|
|
||||||
space={3}
|
|
||||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
|
|
||||||
>
|
|
||||||
<HStack className="text" space={1.5}>
|
|
||||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
|
||||||
<strong>{activeTheme.data.active.label}</strong>
|
|
||||||
<em>(preview)</em>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={1.5} className="w-full">
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
variant="border"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
<Suspense>
|
|
||||||
<Editor
|
|
||||||
defaultValue={[
|
|
||||||
"let foo = { // Demo code editor",
|
|
||||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
|
||||||
" baz: [1, 10.2, null, false, true],",
|
|
||||||
"};",
|
|
||||||
].join("\n")}
|
|
||||||
heightMode="auto"
|
|
||||||
language="javascript"
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</VStack>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { readDir } from "@tauri-apps/plugin-fs";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
|
||||||
import { SelectFile } from "./SelectFile";
|
|
||||||
|
|
||||||
export interface SyncToFilesystemSettingProps {
|
|
||||||
layout?: "form" | "settings";
|
|
||||||
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
|
||||||
onCreateNewWorkspace: () => void;
|
|
||||||
value: { filePath: string | null; initGit?: boolean };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SyncToFilesystemSetting({
|
|
||||||
layout = "form",
|
|
||||||
onChange,
|
|
||||||
onCreateNewWorkspace,
|
|
||||||
value,
|
|
||||||
}: SyncToFilesystemSettingProps) {
|
|
||||||
const [syncDir, setSyncDir] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleFilePathChange = async (filePath: string | null) => {
|
|
||||||
if (filePath != null) {
|
|
||||||
const files = await readDir(filePath);
|
|
||||||
if (files.length > 0) {
|
|
||||||
setSyncDir(filePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSyncDir(null);
|
|
||||||
onChange({ ...value, filePath });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (layout === "settings") {
|
|
||||||
return (
|
|
||||||
<VStack className="w-full" space={0}>
|
|
||||||
{syncDir && (
|
|
||||||
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
|
|
||||||
<p>Directory is not empty. Do you want to open it instead?</p>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="notice"
|
|
||||||
size="xs"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openWorkspaceFromSyncDir.mutate(syncDir);
|
|
||||||
onCreateNewWorkspace();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open Workspace
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingRowDirectory
|
|
||||||
title="Local directory sync"
|
|
||||||
description="Sync data to a folder for backup and Git integration."
|
|
||||||
filePath={value.filePath}
|
|
||||||
onChange={handleFilePathChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value.filePath && typeof value.initGit === "boolean" && (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={value.initGit}
|
|
||||||
title="Initialize Git Repo"
|
|
||||||
description="Create a Git repository in the selected sync directory."
|
|
||||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack className="w-full my-2" space={3}>
|
|
||||||
{syncDir && (
|
|
||||||
<Banner color="notice" className="flex flex-col gap-1.5">
|
|
||||||
<p>Directory is not empty. Do you want to open it instead?</p>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="notice"
|
|
||||||
size="xs"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openWorkspaceFromSyncDir.mutate(syncDir);
|
|
||||||
onCreateNewWorkspace();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open Workspace
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SelectFile
|
|
||||||
directory
|
|
||||||
label="Local directory sync"
|
|
||||||
size="xs"
|
|
||||||
noun="Directory"
|
|
||||||
help="Sync data to a folder for backup and Git integration."
|
|
||||||
filePath={value.filePath}
|
|
||||||
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value.filePath && typeof value.initGit === "boolean" && (
|
|
||||||
<Checkbox
|
|
||||||
checked={value.initGit}
|
|
||||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
|
||||||
title="Initialize Git Repo"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button as BaseButton, type ButtonProps as BaseButtonProps } from "@yaakapp-internal/ui";
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
|
|
||||||
|
|
||||||
export type ButtonProps = BaseButtonProps & {
|
|
||||||
hotkeyAction?: HotkeyAction;
|
|
||||||
hotkeyLabelOnly?: boolean;
|
|
||||||
hotkeyPriority?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
||||||
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
|
|
||||||
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
|
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
|
||||||
ref,
|
|
||||||
() => buttonRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
hotkeyAction ?? null,
|
|
||||||
() => {
|
|
||||||
buttonRef.current?.click();
|
|
||||||
},
|
|
||||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
|
||||||
);
|
|
||||||
|
|
||||||
return <BaseButton ref={buttonRef} title={fullTitle} {...props} />;
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {
|
|
||||||
IconButton as BaseIconButton,
|
|
||||||
type IconButtonProps as BaseIconButtonProps,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
|
|
||||||
|
|
||||||
export type IconButtonProps = BaseIconButtonProps & {
|
|
||||||
hotkeyAction?: HotkeyAction;
|
|
||||||
hotkeyLabelOnly?: boolean;
|
|
||||||
hotkeyPriority?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
||||||
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
|
|
||||||
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
|
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
|
||||||
ref,
|
|
||||||
() => buttonRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
hotkeyAction ?? null,
|
|
||||||
() => {
|
|
||||||
buttonRef.current?.click();
|
|
||||||
},
|
|
||||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
|
||||||
);
|
|
||||||
|
|
||||||
return <BaseIconButton ref={buttonRef} title={fullTitle} {...props} />;
|
|
||||||
});
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
import type { AnyModel } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
|
||||||
import { Checkbox } from "./Checkbox";
|
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
|
||||||
import { PlainInput } from "./PlainInput";
|
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
|
||||||
import { Select } from "./Select";
|
|
||||||
import { SelectFile } from "../SelectFile";
|
|
||||||
|
|
||||||
type ModelKeyOfValue<T, V> = {
|
|
||||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
type SettingRowBaseProps = {
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
title: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
|
||||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
description,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
title: ReactNode | null;
|
|
||||||
}) {
|
|
||||||
const showHeader = title != null || description != null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={classNames(className, "w-full")}>
|
|
||||||
{showHeader && (
|
|
||||||
<div className="border-b border-border-subtle pb-2">
|
|
||||||
{title != null && <div className="text-text-subtle">{title}</div>}
|
|
||||||
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="[&>*:last-child]:border-b-0">{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-disabled={disabled || undefined}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"@container border-b border-border-subtle py-4",
|
|
||||||
disabled && "opacity-disabled",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"grid grid-cols-1 gap-2",
|
|
||||||
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text text-text">{title}</div>
|
|
||||||
{description != null && (
|
|
||||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
|
||||||
controlClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingValue({
|
|
||||||
actions,
|
|
||||||
className,
|
|
||||||
copyText,
|
|
||||||
enableCopy = true,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
actions?: SettingValueAction[];
|
|
||||||
className?: string;
|
|
||||||
copyText?: string;
|
|
||||||
enableCopy?: boolean;
|
|
||||||
value: ReactNode;
|
|
||||||
}) {
|
|
||||||
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
|
|
||||||
const textToCopy = copyText ?? textValue;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{actions?.map((action) => (
|
|
||||||
<IconButton
|
|
||||||
key={action.title}
|
|
||||||
icon={action.icon}
|
|
||||||
title={action.title}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
onClick={action.onClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{enableCopy && textToCopy != null && (
|
|
||||||
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingValueAction = {
|
|
||||||
icon: IconButtonProps["icon"];
|
|
||||||
onClick: () => void;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingRowBoolean({
|
|
||||||
checked,
|
|
||||||
checkboxSize = "md",
|
|
||||||
onChange,
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
checkboxSize?: "sm" | "md";
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size={checkboxSize}
|
|
||||||
checked={checked}
|
|
||||||
disabled={props.disabled}
|
|
||||||
title={title}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
|
|
||||||
return (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={model[modelKey] as boolean}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowNumber({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "!w-48",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "number",
|
|
||||||
validate,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "number";
|
|
||||||
validate?: (value: string) => boolean;
|
|
||||||
value: number;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={`${value}`}
|
|
||||||
validate={validate}
|
|
||||||
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowNumber
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as number}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowText({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "!w-80",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "text",
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "text" | "password";
|
|
||||||
value: string;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={value}
|
|
||||||
onChange={onChange}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowText
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as string}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowFile({
|
|
||||||
buttonClassName,
|
|
||||||
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
|
|
||||||
directory,
|
|
||||||
filePath,
|
|
||||||
nameOverride,
|
|
||||||
noun,
|
|
||||||
onChange,
|
|
||||||
size = "xs",
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
buttonClassName?: string;
|
|
||||||
directory?: boolean;
|
|
||||||
filePath: string | null;
|
|
||||||
nameOverride?: string | null;
|
|
||||||
noun?: string;
|
|
||||||
onChange: (filePath: string | null) => void | Promise<void>;
|
|
||||||
size?: Parameters<typeof SelectFile>[0]["size"];
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} controlClassName={controlClassName} {...props}>
|
|
||||||
<SelectFile
|
|
||||||
directory={directory}
|
|
||||||
inline
|
|
||||||
hideLabel
|
|
||||||
label={typeof title === "string" ? title : noun}
|
|
||||||
size={size}
|
|
||||||
noun={noun}
|
|
||||||
nameOverride={nameOverride}
|
|
||||||
filePath={filePath}
|
|
||||||
className={buttonClassName}
|
|
||||||
onChange={({ filePath }) => onChange(filePath)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowDirectory({
|
|
||||||
noun = "Directory",
|
|
||||||
...props
|
|
||||||
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
|
|
||||||
return <SettingRowFile directory noun={noun} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowSelect<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "!w-48",
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<SettingSelectControl
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
selectClassName={selectClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingSelectControl<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "!w-48",
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
disabled?: boolean;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
label={label}
|
|
||||||
size="sm"
|
|
||||||
className={selectClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingSelectControl<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingSelectControl
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowSelect<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowSelect
|
|
||||||
name={String(modelKey)}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingOverrideRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
onResetOverride,
|
|
||||||
overridden,
|
|
||||||
resetTitle = "Reset override",
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
onResetOverride: () => void;
|
|
||||||
overridden: boolean;
|
|
||||||
resetTitle?: string;
|
|
||||||
title: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
className={className}
|
|
||||||
controlClassName={controlClassName}
|
|
||||||
description={description}
|
|
||||||
disabled={disabled}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{title}
|
|
||||||
{overridden && (
|
|
||||||
<IconButton
|
|
||||||
icon="undo_2"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title={resetTitle}
|
|
||||||
className="text-text-subtle"
|
|
||||||
onClick={onResetOverride}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { GitCommit } from "@yaakapp-internal/git";
|
|
||||||
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
|
|
||||||
export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) {
|
|
||||||
const callbacks = useGitCallbacks(dir);
|
|
||||||
const { restoreFileFromCommit } = useGitMutations(dir, callbacks);
|
|
||||||
const log = useGitLog(dir, undefined, relaPath);
|
|
||||||
const commits = log.data ?? [];
|
|
||||||
const [selectedOid, setSelectedOid] = useState<string | null>(null);
|
|
||||||
const selectedCommit = useMemo(
|
|
||||||
() => commits.find((commit) => commit.oid === selectedOid) ?? null,
|
|
||||||
[commits, selectedOid],
|
|
||||||
);
|
|
||||||
const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (commits.length === 0) {
|
|
||||||
setSelectedOid(null);
|
|
||||||
} else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) {
|
|
||||||
setSelectedOid(commits[0]?.oid ?? null);
|
|
||||||
}
|
|
||||||
}, [commits, selectedOid]);
|
|
||||||
|
|
||||||
const handleRestoreCommit = useCallback(
|
|
||||||
async (commit: GitCommit) => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-restore-file-history-entry",
|
|
||||||
title: "Restore File",
|
|
||||||
description: "This will restore the file to the selected commit.",
|
|
||||||
confirmText: "Restore",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath });
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
[relaPath, restoreFileFromCommit],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (commits.length === 0 && !log.isLoading) {
|
|
||||||
return <EmptyStateText>No history for this file</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full px-2 pb-4">
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="git-file-history-horizontal"
|
|
||||||
layout="horizontal"
|
|
||||||
defaultRatio={0.6}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full overflow-y-auto px-4 pb-2 transform-cpu">
|
|
||||||
<div className="flex flex-col pt-1.5">
|
|
||||||
{commits.map((commit) => (
|
|
||||||
<CommitListItem
|
|
||||||
key={commit.oid}
|
|
||||||
commit={commit}
|
|
||||||
selected={commit.oid === selectedCommit?.oid}
|
|
||||||
onSelect={() => setSelectedOid(commit.oid)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full min-w-0 border-l border-l-border-subtle px-4">
|
|
||||||
{selectedCommit == null ? (
|
|
||||||
<EmptyStateText>Select a commit to view diff</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="mb-2 min-w-0 text-text-subtle grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<div className="min-w-0 truncate">{selectedCommit.message || "No message"}</div>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="warning"
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => handleRestoreCommit(selectedCommit)}
|
|
||||||
>
|
|
||||||
Restore File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DiffViewer
|
|
||||||
original={diff.data?.original ?? ""}
|
|
||||||
modified={diff.data?.modified ?? ""}
|
|
||||||
className="flex-1 min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommitListItem({
|
|
||||||
commit,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
commit: GitCommit;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classNames(
|
|
||||||
"w-full min-w-0 text-left rounded px-2 py-1.5",
|
|
||||||
selected && "bg-surface-active",
|
|
||||||
)}
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
<div className="truncate flex-1">{commit.message || "No message"}</div>
|
|
||||||
<div className="text-text-subtle text-sm truncate">
|
|
||||||
{commit.author.name || "Unknown"} - {formatDistanceToNowStrict(commit.when)} ago - <span className="shrink-0 text-2xs text-text-subtle font-mono">{commit.oid.slice(0, 7)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,681 +0,0 @@
|
|||||||
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useMemo } from "react";
|
|
||||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
|
||||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
|
||||||
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
|
|
||||||
import { showPrompt } from "../../lib/prompt";
|
|
||||||
import { showErrorToast, showToast } from "../../lib/toast";
|
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
|
||||||
import { Dropdown } from "../core/Dropdown";
|
|
||||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
import { GitCommitDialog } from "./GitCommitDialog";
|
|
||||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
|
||||||
import { handlePullResult, handlePushResult } from "./git-util";
|
|
||||||
import { HistoryDialog } from "./HistoryDialog";
|
|
||||||
|
|
||||||
const EMPTY_BRANCHES: string[] = [];
|
|
||||||
|
|
||||||
export function GitDropdown() {
|
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
|
||||||
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 = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
|
|
||||||
const [refreshKey, regenerateKey] = useRandomKey();
|
|
||||||
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
|
|
||||||
const callbacks = useGitCallbacks(syncDir);
|
|
||||||
const {
|
|
||||||
createBranch,
|
|
||||||
deleteBranch,
|
|
||||||
deleteRemoteBranch,
|
|
||||||
renameBranch,
|
|
||||||
mergeBranch,
|
|
||||||
push,
|
|
||||||
pull,
|
|
||||||
checkout,
|
|
||||||
resetChanges,
|
|
||||||
init,
|
|
||||||
} = useGitMutations(syncDir, callbacks);
|
|
||||||
|
|
||||||
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
|
|
||||||
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
|
|
||||||
const remoteOnlyBranches = useMemo(
|
|
||||||
() => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
|
|
||||||
[localBranches, remoteBranches],
|
|
||||||
);
|
|
||||||
const currentBranch = branchInfo.data?.headRefShorthand;
|
|
||||||
const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
|
|
||||||
const ahead = branchInfo.data?.ahead ?? 0;
|
|
||||||
const behind = branchInfo.data?.behind ?? 0;
|
|
||||||
const initRepo = useCallback(() => {
|
|
||||||
init.mutate();
|
|
||||||
}, [init]);
|
|
||||||
|
|
||||||
const items: DropdownItem[] = useMemo(() => {
|
|
||||||
if (workspace == null || branchInfo.data == null) return [];
|
|
||||||
|
|
||||||
const tryCheckout = (branch: string, force: boolean) => {
|
|
||||||
checkout.mutate(
|
|
||||||
{ branch, force },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
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({
|
|
||||||
id: "git-checkout-error",
|
|
||||||
title: "Error checking out branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async onSuccess(branchName) {
|
|
||||||
showToast({
|
|
||||||
id: "git-checkout-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Switched branch <InlineCode>{branchName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "View History...",
|
|
||||||
leftSlot: <Icon icon="history" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
showDialog({
|
|
||||||
id: "git-history",
|
|
||||||
size: "md",
|
|
||||||
title: "Commit History",
|
|
||||||
noPadding: true,
|
|
||||||
render: () => <HistoryDialog dir={syncDir} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Manage Remotes...",
|
|
||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
|
||||||
onSelect: () => GitRemotesDialog.show(syncDir),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
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) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Push",
|
|
||||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await push.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePushResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-push-error",
|
|
||||||
title: "Error pushing changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Pull",
|
|
||||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await pull.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePullResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-pull-error",
|
|
||||||
title: "Error pulling changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Commit...",
|
|
||||||
|
|
||||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
|
||||||
onSelect() {
|
|
||||||
showDialog({
|
|
||||||
id: "commit",
|
|
||||||
title: "Commit Changes",
|
|
||||||
size: "full",
|
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Reset Changes",
|
|
||||||
hidden: !hasChanges,
|
|
||||||
leftSlot: <Icon icon="rotate_ccw" />,
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-reset-changes",
|
|
||||||
title: "Reset Changes",
|
|
||||||
description: "This will discard all uncommitted changes. This cannot be undone.",
|
|
||||||
confirmText: "Reset",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await resetChanges.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-reset-success",
|
|
||||||
message: "Changes have been reset",
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-reset-error",
|
|
||||||
title: "Error resetting changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
|
|
||||||
...localBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
Merge into <InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
hidden: isCurrent,
|
|
||||||
async onSelect() {
|
|
||||||
await mergeBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-merged-branch",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Merged <InlineCode>{branch}</InlineCode> into{" "}
|
|
||||||
<InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-merged-branch-error",
|
|
||||||
title: "Error merging branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "New Branch...",
|
|
||||||
async onSelect() {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "git-new-branch-from",
|
|
||||||
title: "New Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Create a new branch from <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
label: "Branch Name",
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name, base: branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Rename...",
|
|
||||||
async onSelect() {
|
|
||||||
const newName = await showPrompt({
|
|
||||||
id: "git-rename-branch",
|
|
||||||
title: "Rename Branch",
|
|
||||||
label: "New Branch Name",
|
|
||||||
defaultValue: branch,
|
|
||||||
});
|
|
||||||
if (!newName || newName === branch) return;
|
|
||||||
|
|
||||||
await renameBranch.mutateAsync(
|
|
||||||
{ oldName: branch, newName },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-rename-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Renamed <InlineCode>{branch}</InlineCode> to{" "}
|
|
||||||
<InlineCode>{newName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-rename-branch-error",
|
|
||||||
title: "Error renaming branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", hidden: isCurrent },
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: async () => {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-branch",
|
|
||||||
title: "Delete Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-branch-error",
|
|
||||||
title: "Error deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.type === "not_fully_merged") {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "force-branch-delete",
|
|
||||||
title: "Branch not fully merged",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
|
|
||||||
</p>
|
|
||||||
<p>Do you want to delete it anyway?</p>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await deleteBranch.mutateAsync(
|
|
||||||
{ branch, force: true },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-force-delete-branch-error",
|
|
||||||
title: "Error force deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
...remoteOnlyBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-remote-branch",
|
|
||||||
title: "Delete Remote Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await deleteRemoteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-delete-remote-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Deleted remote branch <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-remote-branch-error",
|
|
||||||
title: "Error deleting remote branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}, [
|
|
||||||
branchInfo.data,
|
|
||||||
checkout,
|
|
||||||
createBranch,
|
|
||||||
currentBranch,
|
|
||||||
deleteBranch,
|
|
||||||
deleteRemoteBranch,
|
|
||||||
hasChanges,
|
|
||||||
localBranches,
|
|
||||||
mergeBranch,
|
|
||||||
pull,
|
|
||||||
push,
|
|
||||||
remoteOnlyBranches,
|
|
||||||
renameBranch,
|
|
||||||
resetChanges,
|
|
||||||
syncDir,
|
|
||||||
workspace,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noRepo = branchInfo.error?.includes("not found");
|
|
||||||
if (noRepo) {
|
|
||||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still loading
|
|
||||||
if (branchInfo.data == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
|
||||||
<GitMenuButton>
|
|
||||||
<InlineCode className="flex items-center gap-1">
|
|
||||||
<Icon icon="git_branch" size="xs" className="opacity-50" />
|
|
||||||
{currentBranch}
|
|
||||||
</InlineCode>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{ahead > 0 && (
|
|
||||||
<span className="text-xs flex items-center gap-0.5">
|
|
||||||
<span className="text-primary">↗</span>
|
|
||||||
{ahead}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{behind > 0 && (
|
|
||||||
<span className="text-xs flex items-center gap-0.5">
|
|
||||||
<span className="text-info">↙</span>
|
|
||||||
{behind}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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 outline-none focus-visible:bg-surface-highlight",
|
|
||||||
)}
|
|
||||||
{...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,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: "success",
|
|
||||||
label: "Open Workspace Settings",
|
|
||||||
leftSlot: <Icon icon="settings" />,
|
|
||||||
onSelect: () => openWorkspaceSettings("settings"),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { watchGitWorktreeStatus, type GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
|
||||||
import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { gitWorktreeStatusAtom, gitWorktreeStatusByModelIdAtom } from "../lib/gitWorktreeStatus";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export function initGit() {
|
|
||||||
let watchedDir: string | null = null;
|
|
||||||
let unwatch: null | ReturnType<typeof watchGitWorktreeStatus> = null;
|
|
||||||
|
|
||||||
const watchActiveWorkspace = () => {
|
|
||||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir ?? null;
|
|
||||||
if (syncDir === watchedDir) return;
|
|
||||||
|
|
||||||
void unwatch?.();
|
|
||||||
unwatch = null;
|
|
||||||
watchedDir = syncDir;
|
|
||||||
jotaiStore.set(gitWorktreeStatusAtom, null);
|
|
||||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, {});
|
|
||||||
|
|
||||||
if (syncDir == null) return;
|
|
||||||
|
|
||||||
unwatch = watchGitWorktreeStatus(syncDir, (status) => {
|
|
||||||
if (syncDir !== watchedDir) return;
|
|
||||||
|
|
||||||
jotaiStore.set(gitWorktreeStatusAtom, status);
|
|
||||||
|
|
||||||
const statusByModelId: Record<string, GitWorktreeStatusEntry> = {};
|
|
||||||
for (const entry of status.entries) {
|
|
||||||
if (entry.modelId == null) continue;
|
|
||||||
statusByModelId[entry.modelId] = entry;
|
|
||||||
}
|
|
||||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, statusByModelId);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
watchActiveWorkspace();
|
|
||||||
jotaiStore.sub(activeWorkspaceMetaAtom, watchActiveWorkspace);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { GitWorktreeStatus, GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
import { atomFamily } from "jotai-family";
|
|
||||||
import { selectAtom } from "jotai/utils";
|
|
||||||
|
|
||||||
export const gitWorktreeStatusAtom = atom<GitWorktreeStatus | null>(null);
|
|
||||||
|
|
||||||
export const gitWorktreeStatusByModelIdAtom = atom<Record<string, GitWorktreeStatusEntry>>({});
|
|
||||||
|
|
||||||
export const gitWorktreeStatusFamily = atomFamily(
|
|
||||||
(modelId: string) =>
|
|
||||||
selectAtom(
|
|
||||||
gitWorktreeStatusByModelIdAtom,
|
|
||||||
(statusByModelId) => statusByModelId[modelId] ?? null,
|
|
||||||
(a, b) =>
|
|
||||||
a?.relaPath === b?.relaPath &&
|
|
||||||
a?.status === b?.status &&
|
|
||||||
a?.staged === b?.staged &&
|
|
||||||
a?.modelId === b?.modelId,
|
|
||||||
),
|
|
||||||
Object.is,
|
|
||||||
);
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import type { AnyModel, Workspace } from "@yaakapp-internal/models";
|
|
||||||
|
|
||||||
type ModelType = AnyModel["model"];
|
|
||||||
|
|
||||||
type WorkspaceRequestSettings = Pick<
|
|
||||||
Workspace,
|
|
||||||
| "settingFollowRedirects"
|
|
||||||
| "settingRequestTimeout"
|
|
||||||
| "settingSendCookies"
|
|
||||||
| "settingStoreCookies"
|
|
||||||
| "settingValidateCertificates"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type ModelForType<T extends ModelType> = Extract<AnyModel, { model: T }>;
|
|
||||||
|
|
||||||
type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
|
||||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
|
||||||
}[ModelType];
|
|
||||||
|
|
||||||
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
|
||||||
defaultValue: WorkspaceRequestSettings[K];
|
|
||||||
description: string;
|
|
||||||
modelKey: K;
|
|
||||||
models: readonly ModelTypeWithSetting<K>[];
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RequestSettingKey = keyof WorkspaceRequestSettings;
|
|
||||||
|
|
||||||
function defineRequestSetting<const K extends RequestSettingKey>(
|
|
||||||
setting: RequestSettingDefinition<K>,
|
|
||||||
) {
|
|
||||||
return setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
|
||||||
defaultValue: 0,
|
|
||||||
description: "Maximum request duration in milliseconds. Set to 0 to disable.",
|
|
||||||
modelKey: "settingRequestTimeout",
|
|
||||||
models: ["workspace", "folder", "http_request"],
|
|
||||||
title: "Request Timeout",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
|
||||||
defaultValue: true,
|
|
||||||
description: "When disabled, skip validation of server certificates.",
|
|
||||||
modelKey: "settingValidateCertificates",
|
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
|
||||||
title: "Validate TLS certificates",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
|
||||||
defaultValue: true,
|
|
||||||
description: "Follow HTTP redirects automatically.",
|
|
||||||
modelKey: "settingFollowRedirects",
|
|
||||||
models: ["workspace", "folder", "http_request"],
|
|
||||||
title: "Follow redirects",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
|
||||||
defaultValue: true,
|
|
||||||
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
|
||||||
modelKey: "settingSendCookies",
|
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
|
||||||
title: "Automatically send cookies",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
|
||||||
defaultValue: true,
|
|
||||||
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
|
||||||
modelKey: "settingStoreCookies",
|
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
|
||||||
title: "Automatically store cookies",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function modelSupportsSetting<K extends RequestSettingKey>(
|
|
||||||
model: Pick<AnyModel, "model">,
|
|
||||||
setting: RequestSettingDefinition<K>,
|
|
||||||
) {
|
|
||||||
return setting.models.some((modelType) => modelType === model.model);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export type { Appearance } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
getCSSAppearance,
|
|
||||||
getWindowAppearance,
|
|
||||||
resolveAppearance,
|
|
||||||
subscribeToPreferredAppearance,
|
|
||||||
subscribeToWindowAppearanceChange,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
|
||||||
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
|
||||||
import { invokeCmd } from "../tauri";
|
|
||||||
import type { Appearance } from "./appearance";
|
|
||||||
import { resolveAppearance } from "./appearance";
|
|
||||||
|
|
||||||
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
|
||||||
|
|
||||||
export async function getThemes() {
|
|
||||||
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
|
||||||
themes.sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
// Remove duplicates, in case multiple plugins provide the same theme
|
|
||||||
const uniqueThemes = Array.from(new Map(themes.map((t) => [t.id, t])).values());
|
|
||||||
return { themes: [defaultDarkTheme, defaultLightTheme, ...uniqueThemes] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getResolvedTheme(
|
|
||||||
preferredAppearance: Appearance,
|
|
||||||
appearanceSetting: string,
|
|
||||||
themeLight: string,
|
|
||||||
themeDark: string,
|
|
||||||
) {
|
|
||||||
const appearance = resolveAppearance(preferredAppearance, appearanceSetting);
|
|
||||||
const { themes } = await getThemes();
|
|
||||||
|
|
||||||
const darkThemes = themes.filter((t) => t.dark);
|
|
||||||
const lightThemes = themes.filter((t) => !t.dark);
|
|
||||||
|
|
||||||
const dark = darkThemes.find((t) => t.id === themeDark) ?? darkThemes[0] ?? defaultDarkTheme;
|
|
||||||
const light = lightThemes.find((t) => t.id === themeLight) ?? lightThemes[0] ?? defaultLightTheme;
|
|
||||||
|
|
||||||
const active = appearance === "dark" ? dark : light;
|
|
||||||
|
|
||||||
return { dark, light, active };
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
addThemeStylesToDocument,
|
|
||||||
applyThemeToDocument,
|
|
||||||
completeTheme,
|
|
||||||
getThemeCSS,
|
|
||||||
indent,
|
|
||||||
setThemeOnDocument,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { YaakColor } from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
|
|
||||||
// This file was automatically generated by TanStack Router.
|
|
||||||
// You should NOT make any changes in this file as it will be overwritten.
|
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
|
||||||
import { Route as WorkspacesIndexRouteImport } from './routes/workspaces/index'
|
|
||||||
import { Route as WorkspacesWorkspaceIdIndexRouteImport } from './routes/workspaces/$workspaceId/index'
|
|
||||||
import { Route as WorkspacesWorkspaceIdSettingsRouteImport } from './routes/workspaces/$workspaceId/settings'
|
|
||||||
import { Route as WorkspacesWorkspaceIdRequestsRequestIdRouteImport } from './routes/workspaces/$workspaceId/requests/$requestId'
|
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
|
||||||
id: '/',
|
|
||||||
path: '/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const WorkspacesIndexRoute = WorkspacesIndexRouteImport.update({
|
|
||||||
id: '/workspaces/',
|
|
||||||
path: '/workspaces/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const WorkspacesWorkspaceIdIndexRoute =
|
|
||||||
WorkspacesWorkspaceIdIndexRouteImport.update({
|
|
||||||
id: '/workspaces/$workspaceId/',
|
|
||||||
path: '/workspaces/$workspaceId/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const WorkspacesWorkspaceIdSettingsRoute =
|
|
||||||
WorkspacesWorkspaceIdSettingsRouteImport.update({
|
|
||||||
id: '/workspaces/$workspaceId/settings',
|
|
||||||
path: '/workspaces/$workspaceId/settings',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const WorkspacesWorkspaceIdRequestsRequestIdRoute =
|
|
||||||
WorkspacesWorkspaceIdRequestsRequestIdRouteImport.update({
|
|
||||||
id: '/workspaces/$workspaceId/requests/$requestId',
|
|
||||||
path: '/workspaces/$workspaceId/requests/$requestId',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/workspaces': typeof WorkspacesIndexRoute
|
|
||||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
|
||||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
|
|
||||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
|
||||||
}
|
|
||||||
export interface FileRoutesByTo {
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/workspaces': typeof WorkspacesIndexRoute
|
|
||||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
|
||||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
|
|
||||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
|
||||||
}
|
|
||||||
export interface FileRoutesById {
|
|
||||||
__root__: typeof rootRouteImport
|
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/workspaces/': typeof WorkspacesIndexRoute
|
|
||||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
|
||||||
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
|
|
||||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
|
||||||
}
|
|
||||||
export interface FileRouteTypes {
|
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
|
||||||
fullPaths:
|
|
||||||
| '/'
|
|
||||||
| '/workspaces'
|
|
||||||
| '/workspaces/$workspaceId/settings'
|
|
||||||
| '/workspaces/$workspaceId'
|
|
||||||
| '/workspaces/$workspaceId/requests/$requestId'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
|
||||||
to:
|
|
||||||
| '/'
|
|
||||||
| '/workspaces'
|
|
||||||
| '/workspaces/$workspaceId/settings'
|
|
||||||
| '/workspaces/$workspaceId'
|
|
||||||
| '/workspaces/$workspaceId/requests/$requestId'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
|
||||||
| '/workspaces/'
|
|
||||||
| '/workspaces/$workspaceId/settings'
|
|
||||||
| '/workspaces/$workspaceId/'
|
|
||||||
| '/workspaces/$workspaceId/requests/$requestId'
|
|
||||||
fileRoutesById: FileRoutesById
|
|
||||||
}
|
|
||||||
export interface RootRouteChildren {
|
|
||||||
IndexRoute: typeof IndexRoute
|
|
||||||
WorkspacesIndexRoute: typeof WorkspacesIndexRoute
|
|
||||||
WorkspacesWorkspaceIdSettingsRoute: typeof WorkspacesWorkspaceIdSettingsRoute
|
|
||||||
WorkspacesWorkspaceIdIndexRoute: typeof WorkspacesWorkspaceIdIndexRoute
|
|
||||||
WorkspacesWorkspaceIdRequestsRequestIdRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
|
||||||
interface FileRoutesByPath {
|
|
||||||
'/': {
|
|
||||||
id: '/'
|
|
||||||
path: '/'
|
|
||||||
fullPath: '/'
|
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/workspaces/': {
|
|
||||||
id: '/workspaces/'
|
|
||||||
path: '/workspaces'
|
|
||||||
fullPath: '/workspaces'
|
|
||||||
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/workspaces/$workspaceId/': {
|
|
||||||
id: '/workspaces/$workspaceId/'
|
|
||||||
path: '/workspaces/$workspaceId'
|
|
||||||
fullPath: '/workspaces/$workspaceId'
|
|
||||||
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/workspaces/$workspaceId/settings': {
|
|
||||||
id: '/workspaces/$workspaceId/settings'
|
|
||||||
path: '/workspaces/$workspaceId/settings'
|
|
||||||
fullPath: '/workspaces/$workspaceId/settings'
|
|
||||||
preLoaderRoute: typeof WorkspacesWorkspaceIdSettingsRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/workspaces/$workspaceId/requests/$requestId': {
|
|
||||||
id: '/workspaces/$workspaceId/requests/$requestId'
|
|
||||||
path: '/workspaces/$workspaceId/requests/$requestId'
|
|
||||||
fullPath: '/workspaces/$workspaceId/requests/$requestId'
|
|
||||||
preLoaderRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
|
||||||
IndexRoute: IndexRoute,
|
|
||||||
WorkspacesIndexRoute: WorkspacesIndexRoute,
|
|
||||||
WorkspacesWorkspaceIdSettingsRoute: WorkspacesWorkspaceIdSettingsRoute,
|
|
||||||
WorkspacesWorkspaceIdIndexRoute: WorkspacesWorkspaceIdIndexRoute,
|
|
||||||
WorkspacesWorkspaceIdRequestsRequestIdRoute:
|
|
||||||
WorkspacesWorkspaceIdRequestsRequestIdRoute,
|
|
||||||
}
|
|
||||||
export const routeTree = rootRouteImport
|
|
||||||
._addFileChildren(rootRouteChildren)
|
|
||||||
._addFileTypes<FileRouteTypes>()
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
const sharedConfig = require("@yaakapp-internal/tailwind-config");
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
...sharedConfig,
|
|
||||||
content: [
|
|
||||||
"./*.{html,ts,tsx}",
|
|
||||||
"./commands/**/*.{ts,tsx}",
|
|
||||||
"./components/**/*.{ts,tsx}",
|
|
||||||
"./hooks/**/*.{ts,tsx}",
|
|
||||||
"./init/**/*.{ts,tsx}",
|
|
||||||
"./lib/**/*.{ts,tsx}",
|
|
||||||
"./routes/**/*.{ts,tsx}",
|
|
||||||
"../../packages/ui/src/**/*.{ts,tsx}",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2021",
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"paths": {
|
|
||||||
"@yaakapp-internal/theme": ["../../packages/theme/src/index.ts"],
|
|
||||||
"@yaakapp-internal/theme/*": ["../../packages/theme/src/*"],
|
|
||||||
"@yaakapp-internal/ui": ["../../packages/ui/src/index.ts"],
|
|
||||||
"@yaakapp-internal/ui/*": ["../../packages/ui/src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["."],
|
|
||||||
"exclude": ["vite.config.ts"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// @ts-ignore
|
|
||||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import path from "node:path";
|
|
||||||
import { defineConfig, normalizePath } from "vite-plus";
|
|
||||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
|
||||||
import svgr from "vite-plugin-svgr";
|
|
||||||
import topLevelAwait from "vite-plugin-top-level-await";
|
|
||||||
import wasm from "vite-plugin-wasm";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const cMapsDir = normalizePath(
|
|
||||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "cmaps"),
|
|
||||||
);
|
|
||||||
const standardFontsDir = normalizePath(
|
|
||||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig(async () => {
|
|
||||||
return {
|
|
||||||
plugins: [
|
|
||||||
wasm(),
|
|
||||||
tanstackRouter({
|
|
||||||
target: "react",
|
|
||||||
routesDirectory: "./routes",
|
|
||||||
generatedRouteTree: "./routeTree.gen.ts",
|
|
||||||
autoCodeSplitting: true,
|
|
||||||
}),
|
|
||||||
svgr(),
|
|
||||||
react(),
|
|
||||||
topLevelAwait(),
|
|
||||||
viteStaticCopy({
|
|
||||||
targets: [
|
|
||||||
{ src: cMapsDir, dest: "" },
|
|
||||||
{ src: standardFontsDir, dest: "" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
build: {
|
|
||||||
sourcemap: true,
|
|
||||||
outDir: "../../dist/apps/yaak-client",
|
|
||||||
emptyOutDir: true,
|
|
||||||
rolldownOptions: {
|
|
||||||
output: {
|
|
||||||
// Make chunk names readable
|
|
||||||
chunkFileNames: "assets/chunk-[name]-[hash].js",
|
|
||||||
entryFileNames: "assets/entry-[name]-[hash].js",
|
|
||||||
assetFileNames: "assets/asset-[name]-[hash][extname]",
|
|
||||||
// Vite-Plus/Rolldown 0.1.20 can emit a stale style-mod export when
|
|
||||||
// top-level var rewriting combines with OXC minification.
|
|
||||||
topLevelVar: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clearScreen: false,
|
|
||||||
server: {
|
|
||||||
port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10),
|
|
||||||
strictPort: true,
|
|
||||||
},
|
|
||||||
envPrefix: ["VITE_", "TAURI_"],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { ActionInvocation } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import { Button, type ButtonProps } from "@yaakapp-internal/ui";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
|
||||||
import { useActionMetadata } from "../hooks/useActionMetadata";
|
|
||||||
|
|
||||||
type ActionButtonProps = Omit<ButtonProps, "onClick" | "children"> & {
|
|
||||||
action: ActionInvocation;
|
|
||||||
/** Override the label from metadata */
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ActionButton({ action, children, ...props }: ActionButtonProps) {
|
|
||||||
const meta = useActionMetadata(action);
|
|
||||||
const { mutate, isPending } = useRpcMutation("execute_action");
|
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
|
||||||
mutate(action);
|
|
||||||
}, [action, mutate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
disabled={props.disabled || isPending}
|
|
||||||
isLoading={isPending}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children ?? meta?.label ?? "…"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { ActionInvocation } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import { IconButton, type IconButtonProps } from "@yaakapp-internal/ui";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
|
||||||
import { useActionMetadata } from "../hooks/useActionMetadata";
|
|
||||||
|
|
||||||
type ActionIconButtonProps = Omit<IconButtonProps, "onClick" | "title"> & {
|
|
||||||
action: ActionInvocation;
|
|
||||||
title?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ActionIconButton({ action, ...props }: ActionIconButtonProps) {
|
|
||||||
const meta = useActionMetadata(action);
|
|
||||||
const { mutate, isPending } = useRpcMutation("execute_action");
|
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
|
||||||
mutate(action);
|
|
||||||
}, [action, mutate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
{...props}
|
|
||||||
title={props.title ?? meta?.label ?? "…"}
|
|
||||||
disabled={props.disabled || isPending}
|
|
||||||
isLoading={isPending}
|
|
||||||
onClick={onClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { HttpExchange, ProxyHeader } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
TruncatedWideTableCell,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exchanges: HttpExchange[];
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExchangesTable({ exchanges, style, className }: Props) {
|
|
||||||
if (exchanges.length === 0) {
|
|
||||||
return <p className="text-text-subtlest text-sm">No traffic yet</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} style={style}>
|
|
||||||
<Table scrollable className="px-2">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell>Method</TableHeaderCell>
|
|
||||||
<TableHeaderCell>URL</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Type</TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{exchanges.map((ex) => (
|
|
||||||
<TableRow key={ex.id}>
|
|
||||||
<TableCell className="font-mono text-2xs">{ex.method}</TableCell>
|
|
||||||
<TruncatedWideTableCell className="font-mono text-2xs">
|
|
||||||
{ex.url}
|
|
||||||
</TruncatedWideTableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={ex.resStatus} error={ex.error} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-text-subtle text-xs">
|
|
||||||
{getContentType(ex.resHeaders)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status, error }: { status: number | null; error: string | null }) {
|
|
||||||
if (error) return <span className="text-xs text-danger">Error</span>;
|
|
||||||
if (status == null) return <span className="text-xs text-text-subtlest">—</span>;
|
|
||||||
|
|
||||||
const color =
|
|
||||||
status >= 500
|
|
||||||
? "text-danger"
|
|
||||||
: status >= 400
|
|
||||||
? "text-warning"
|
|
||||||
: status >= 300
|
|
||||||
? "text-notice"
|
|
||||||
: "text-success";
|
|
||||||
|
|
||||||
return <span className={classNames("text-xs font-mono", color)}>{status}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContentType(headers: ProxyHeader[]): string {
|
|
||||||
const ct = headers.find((h) => h.name.toLowerCase() === "content-type")?.value;
|
|
||||||
if (ct == null) return "—";
|
|
||||||
// Strip parameters (e.g. "; charset=utf-8")
|
|
||||||
return ct.split(";")[0]?.trim() ?? ct;
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useLocalStorage } from "react-use";
|
|
||||||
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
|
|
||||||
import { getOsType } from "../lib/tauri";
|
|
||||||
import { ActionIconButton } from "./ActionIconButton";
|
|
||||||
import { ExchangesTable } from "./ExchangesTable";
|
|
||||||
import { filteredExchangesAtom, Sidebar } from "./Sidebar";
|
|
||||||
|
|
||||||
export function ProxyLayout() {
|
|
||||||
const os = getOsType();
|
|
||||||
const exchanges = useAtomValue(filteredExchangesAtom);
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
|
||||||
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
|
||||||
"floating_sidebar_hidden",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const [floating, setFloating] = useState(false);
|
|
||||||
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
|
|
||||||
const isRunning = proxyState?.state === "running";
|
|
||||||
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"h-full w-full grid grid-rows-[auto_1fr]",
|
|
||||||
os === "linux" && "border border-border-subtle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HeaderSize
|
|
||||||
data-tauri-drag-region
|
|
||||||
size="lg"
|
|
||||||
osType={os}
|
|
||||||
hideWindowControls={false}
|
|
||||||
useNativeTitlebar={false}
|
|
||||||
interfaceScale={1}
|
|
||||||
className="x-theme-appHeader bg-surface"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full pointer-events-none">
|
|
||||||
<div className="flex items-center pl-1 pointer-events-auto">
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
title="Toggle sidebar"
|
|
||||||
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
|
|
||||||
iconColor="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
if (floating) {
|
|
||||||
setFloatingSidebarHidden(!floatingSidebarHidden);
|
|
||||||
} else {
|
|
||||||
setSidebarHidden(!sidebarHidden);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="pointer-events-none flex items-center text-sm px-2"
|
|
||||||
>
|
|
||||||
Yaak Proxy
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 pr-1 pointer-events-auto">
|
|
||||||
{isRunning ? (
|
|
||||||
<>
|
|
||||||
<span className="text-2xs text-success">Running :9090</span>
|
|
||||||
<ActionIconButton
|
|
||||||
action={{ scope: "global", action: "proxy_stop" }}
|
|
||||||
icon="circle_stop"
|
|
||||||
iconColor="secondary"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ActionIconButton
|
|
||||||
action={{ scope: "global", action: "proxy_start" }}
|
|
||||||
icon="circle_play"
|
|
||||||
iconColor="secondary"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HeaderSize>
|
|
||||||
<SidebarLayout
|
|
||||||
width={sidebarWidth ?? 250}
|
|
||||||
onWidthChange={setSidebarWidth}
|
|
||||||
hidden={sidebarHidden ?? false}
|
|
||||||
onHiddenChange={setSidebarHidden}
|
|
||||||
floatingHidden={floatingSidebarHidden ?? true}
|
|
||||||
onFloatingHiddenChange={setFloatingSidebarHidden}
|
|
||||||
onFloatingChange={setFloating}
|
|
||||||
sidebar={
|
|
||||||
floating ? (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"x-theme-sidebar",
|
|
||||||
"h-full bg-surface border-r border-border-subtle",
|
|
||||||
"grid grid-rows-[auto_1fr]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HeaderSize
|
|
||||||
hideControls
|
|
||||||
size="lg"
|
|
||||||
className="border-transparent pl-1"
|
|
||||||
osType={os}
|
|
||||||
hideWindowControls={false}
|
|
||||||
useNativeTitlebar={false}
|
|
||||||
interfaceScale={1}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
title="Toggle sidebar"
|
|
||||||
icon="left_panel_visible"
|
|
||||||
iconColor="secondary"
|
|
||||||
onClick={() => setFloatingSidebarHidden(true)}
|
|
||||||
/>
|
|
||||||
</HeaderSize>
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Sidebar />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="proxy_detail"
|
|
||||||
layout="vertical"
|
|
||||||
defaultRatio={0.4}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<ExchangesTable exchanges={exchanges} style={style} className="overflow-auto" />
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className="p-3 text-text-subtlest text-sm border-t border-border-subtle"
|
|
||||||
>
|
|
||||||
Select a request to view details
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SidebarLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
|
||||||
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
|
||||||
import { atom, useAtomValue } from "jotai";
|
|
||||||
import { atomFamily } from "jotai-family";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { httpExchangesAtom } from "../lib/store";
|
|
||||||
|
|
||||||
/** A node in the sidebar tree — either a domain or a path segment. */
|
|
||||||
export type SidebarItem = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
exchangeIds: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapsedAtom = atomFamily((_treeId: string) => atom<Record<string, boolean>>({}));
|
|
||||||
|
|
||||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
|
||||||
|
|
||||||
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
|
||||||
const exchanges = get(httpExchangesAtom);
|
|
||||||
return buildTree(exchanges);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Exchanges filtered by the currently selected sidebar node(s). */
|
|
||||||
export const filteredExchangesAtom = atom((get) => {
|
|
||||||
const exchanges = get(httpExchangesAtom);
|
|
||||||
const tree = get(sidebarTreeAtom);
|
|
||||||
const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID));
|
|
||||||
|
|
||||||
// Nothing selected or root selected → show all
|
|
||||||
if (selectedIds.length === 0 || selectedIds.includes("root")) {
|
|
||||||
return exchanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect exchange IDs from all selected nodes
|
|
||||||
const allowedIds = new Set<string>();
|
|
||||||
const nodeMap = new Map<string, SidebarItem>();
|
|
||||||
collectNodes(tree, nodeMap);
|
|
||||||
|
|
||||||
for (const selectedId of selectedIds) {
|
|
||||||
const node = nodeMap.get(selectedId);
|
|
||||||
if (node) {
|
|
||||||
for (const id of node.exchangeIds) {
|
|
||||||
allowedIds.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return exchanges.filter((ex) => allowedIds.has(ex.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
function collectNodes(node: TreeNode<SidebarItem>, map: Map<string, SidebarItem>) {
|
|
||||||
map.set(node.item.id, node.item);
|
|
||||||
for (const child of node.children ?? []) {
|
|
||||||
collectNodes(child, map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a domain → path-segment trie from a flat list of exchanges.
|
|
||||||
*
|
|
||||||
* Example: Given URLs
|
|
||||||
* GET https://api.example.com/v1/users
|
|
||||||
* GET https://api.example.com/v1/users/123
|
|
||||||
* POST https://api.example.com/v1/orders
|
|
||||||
*
|
|
||||||
* Produces:
|
|
||||||
* api.example.com
|
|
||||||
* /v1
|
|
||||||
* /users
|
|
||||||
* /123
|
|
||||||
* /orders
|
|
||||||
*/
|
|
||||||
function buildTree(exchanges: HttpExchange[]): TreeNode<SidebarItem> {
|
|
||||||
const root: SidebarItem = { id: "root", label: "All Traffic", exchangeIds: [] };
|
|
||||||
const rootNode: TreeNode<SidebarItem> = {
|
|
||||||
item: root,
|
|
||||||
parent: null,
|
|
||||||
depth: 0,
|
|
||||||
children: [],
|
|
||||||
draggable: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Intermediate trie structure for building
|
|
||||||
type TrieNode = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
exchangeIds: string[];
|
|
||||||
children: Map<string, TrieNode>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const domainMap = new Map<string, TrieNode>();
|
|
||||||
|
|
||||||
for (const ex of exchanges) {
|
|
||||||
let hostname: string;
|
|
||||||
let segments: string[];
|
|
||||||
try {
|
|
||||||
const url = new URL(ex.url);
|
|
||||||
hostname = url.host;
|
|
||||||
segments = url.pathname.split("/").filter(Boolean);
|
|
||||||
} catch {
|
|
||||||
hostname = ex.url;
|
|
||||||
segments = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create domain node
|
|
||||||
let domainNode = domainMap.get(hostname);
|
|
||||||
if (!domainNode) {
|
|
||||||
domainNode = {
|
|
||||||
id: `domain:${hostname}`,
|
|
||||||
label: hostname,
|
|
||||||
exchangeIds: [],
|
|
||||||
children: new Map(),
|
|
||||||
};
|
|
||||||
domainMap.set(hostname, domainNode);
|
|
||||||
}
|
|
||||||
domainNode.exchangeIds.push(ex.id);
|
|
||||||
|
|
||||||
// Walk path segments
|
|
||||||
let current = domainNode;
|
|
||||||
const pathSoFar: string[] = [];
|
|
||||||
for (const seg of segments) {
|
|
||||||
pathSoFar.push(seg);
|
|
||||||
let child = current.children.get(seg);
|
|
||||||
if (!child) {
|
|
||||||
child = {
|
|
||||||
id: `path:${hostname}/${pathSoFar.join("/")}`,
|
|
||||||
label: `/${seg}`,
|
|
||||||
exchangeIds: [],
|
|
||||||
children: new Map(),
|
|
||||||
};
|
|
||||||
current.children.set(seg, child);
|
|
||||||
}
|
|
||||||
child.exchangeIds.push(ex.id);
|
|
||||||
current = child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert trie to TreeNode structure
|
|
||||||
function toTreeNode(
|
|
||||||
trie: TrieNode,
|
|
||||||
parent: TreeNode<SidebarItem>,
|
|
||||||
depth: number,
|
|
||||||
): TreeNode<SidebarItem> {
|
|
||||||
const node: TreeNode<SidebarItem> = {
|
|
||||||
item: {
|
|
||||||
id: trie.id,
|
|
||||||
label: trie.label,
|
|
||||||
exchangeIds: trie.exchangeIds,
|
|
||||||
},
|
|
||||||
parent,
|
|
||||||
depth,
|
|
||||||
children: [],
|
|
||||||
draggable: false,
|
|
||||||
};
|
|
||||||
const sortedChildren = [...trie.children.values()].sort((a, b) =>
|
|
||||||
a.label.localeCompare(b.label),
|
|
||||||
);
|
|
||||||
for (const child of sortedChildren) {
|
|
||||||
node.children?.push(toTreeNode(child, node, depth + 1));
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a "Domains" folder between root and domain nodes
|
|
||||||
const allExchangeIds = exchanges.map((ex) => ex.id);
|
|
||||||
const domainsFolder: TreeNode<SidebarItem> = {
|
|
||||||
item: { id: "domains", label: "Domains", exchangeIds: allExchangeIds },
|
|
||||||
parent: rootNode,
|
|
||||||
depth: 1,
|
|
||||||
children: [],
|
|
||||||
draggable: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedDomains = [...domainMap.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
for (const domain of sortedDomains) {
|
|
||||||
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
rootNode.children?.push(domainsFolder);
|
|
||||||
|
|
||||||
return rootNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemInner({ item }: { item: SidebarItem }) {
|
|
||||||
const count = item.exchangeIds.length;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 w-full min-w-0">
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar() {
|
|
||||||
const tree = useAtomValue(sidebarTreeAtom);
|
|
||||||
const treeId = SIDEBAR_TREE_ID;
|
|
||||||
|
|
||||||
const getItemKey = useCallback(
|
|
||||||
(item: SidebarItem) => `${item.id}:${item.exchangeIds.length}`,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 overflow-y-auto border-r border-border-subtle">
|
|
||||||
<div className="pt-2 text-xs">
|
|
||||||
<Tree
|
|
||||||
treeId={treeId}
|
|
||||||
collapsedAtom={collapsedAtom(treeId)}
|
|
||||||
className="px-2 pb-10"
|
|
||||||
root={tree}
|
|
||||||
getItemKey={getItemKey}
|
|
||||||
ItemInner={ItemInner}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Hardcode font size for now. In the future, this could be configurable.
|
|
||||||
document.documentElement.style.fontSize = "15px";
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
|
|
||||||
/** Look up metadata for a specific action invocation. */
|
|
||||||
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
|
|
||||||
export function useActionMetadata(action: ActionInvocation): ActionMetadata | null {
|
|
||||||
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
|
|
||||||
const [meta, setMeta] = useState<ActionMetadata | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void getActions().then((actions) => {
|
|
||||||
const match = actions.find(
|
|
||||||
([inv]) => inv.scope === action.scope && inv.action === action.action,
|
|
||||||
);
|
|
||||||
setMeta(match?.[1] ?? null);
|
|
||||||
});
|
|
||||||
}, [action]);
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedActions: [ActionInvocation, ActionMetadata][] | null = null;
|
|
||||||
|
|
||||||
/** Fetch and cache all action metadata. */
|
|
||||||
async function getActions(): Promise<[ActionInvocation, ActionMetadata][]> {
|
|
||||||
if (!cachedActions) {
|
|
||||||
const { actions } = await rpc("list_actions", {});
|
|
||||||
cachedActions = actions;
|
|
||||||
}
|
|
||||||
return cachedActions;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { RpcEventSchema } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { listen } from "../lib/rpc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to an RPC event. Cleans up automatically on unmount.
|
|
||||||
*/
|
|
||||||
export function useRpcEvent<K extends keyof RpcEventSchema>(
|
|
||||||
event: K & string,
|
|
||||||
callback: (payload: RpcEventSchema[K]) => void,
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
return listen(event, callback);
|
|
||||||
}, [event, callback]);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { type UseMutationOptions, useMutation } from "@tanstack/react-query";
|
|
||||||
import type { RpcSchema } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import { minPromiseMillis } from "@yaakapp-internal/ui";
|
|
||||||
import type { Req, Res } from "../lib/rpc";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Query mutation wrapper for RPC commands.
|
|
||||||
*/
|
|
||||||
export function useRpcMutation<K extends keyof RpcSchema>(
|
|
||||||
cmd: K,
|
|
||||||
opts?: Omit<UseMutationOptions<Res<K>, Error, Req<K>>, "mutationFn">,
|
|
||||||
) {
|
|
||||||
return useMutation<Res<K>, Error, Req<K>>({
|
|
||||||
mutationFn: (payload) => minPromiseMillis(rpc(cmd, payload)),
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
|
|
||||||
import type { RpcSchema } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import type { Req, Res } from "../lib/rpc";
|
|
||||||
import { rpc } from "../lib/rpc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Query wrapper for RPC commands.
|
|
||||||
* Automatically caches by [cmd, payload] and supports all useQuery options.
|
|
||||||
*/
|
|
||||||
export function useRpcQuery<K extends keyof RpcSchema>(
|
|
||||||
cmd: K,
|
|
||||||
payload: Req<K>,
|
|
||||||
opts?: Omit<UseQueryOptions<Res<K>>, "queryKey" | "queryFn">,
|
|
||||||
) {
|
|
||||||
return useQuery<Res<K>>({
|
|
||||||
queryKey: [cmd, payload],
|
|
||||||
queryFn: () => rpc(cmd, payload),
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { type UseQueryOptions, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { RpcEventSchema, RpcSchema } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import type { Req, Res } from "../lib/rpc";
|
|
||||||
import { useRpcEvent } from "./useRpcEvent";
|
|
||||||
import { useRpcQuery } from "./useRpcQuery";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combines useRpcQuery with an event listener that invalidates the query
|
|
||||||
* whenever the specified event fires, keeping data fresh automatically.
|
|
||||||
*/
|
|
||||||
export function useRpcQueryWithEvent<
|
|
||||||
K extends keyof RpcSchema,
|
|
||||||
E extends keyof RpcEventSchema,
|
|
||||||
>(cmd: K, payload: Req<K>, event: E, opts?: Omit<UseQueryOptions<Res<K>>, "queryKey" | "queryFn">) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const query = useRpcQuery(cmd, payload, opts);
|
|
||||||
|
|
||||||
useRpcEvent(event, () => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: [cmd, payload] });
|
|
||||||
});
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Yaak Proxy</title>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
background-color: #1b1a29;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="text-base">
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/font-size.ts"></script>
|
|
||||||
<script type="module" src="/lib/theme.ts"></script>
|
|
||||||
<script type="module" src="/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export function fireAndForget(promise: Promise<unknown>) {
|
|
||||||
promise.catch((err: unknown) => {
|
|
||||||
console.error("Unhandled async error:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import { rpc } from "./rpc";
|
|
||||||
|
|
||||||
type ActionBinding = {
|
|
||||||
invocation: ActionInvocation;
|
|
||||||
meta: ActionMetadata;
|
|
||||||
keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */
|
|
||||||
function parseHotkey(hotkey: string): ActionBinding["keys"] {
|
|
||||||
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
|
|
||||||
return {
|
|
||||||
ctrl: parts.includes("ctrl") || parts.includes("control"),
|
|
||||||
shift: parts.includes("shift"),
|
|
||||||
alt: parts.includes("alt"),
|
|
||||||
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"),
|
|
||||||
key:
|
|
||||||
parts.filter(
|
|
||||||
(p) => !["ctrl", "control", "shift", "alt", "meta", "cmd", "command"].includes(p),
|
|
||||||
)[0] ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean {
|
|
||||||
return (
|
|
||||||
e.ctrlKey === binding.ctrl &&
|
|
||||||
e.shiftKey === binding.shift &&
|
|
||||||
e.altKey === binding.alt &&
|
|
||||||
e.metaKey === binding.meta &&
|
|
||||||
e.key.toLowerCase() === binding.key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch all actions from Rust and register a global keydown listener. */
|
|
||||||
export async function initHotkeys(): Promise<() => void> {
|
|
||||||
const { actions } = await rpc("list_actions", {});
|
|
||||||
|
|
||||||
const bindings: ActionBinding[] = actions
|
|
||||||
.filter(
|
|
||||||
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
|
|
||||||
(entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
|
|
||||||
entry[1].defaultHotkey != null,
|
|
||||||
)
|
|
||||||
.map(([invocation, meta]) => ({
|
|
||||||
invocation,
|
|
||||||
meta,
|
|
||||||
keys: parseHotkey(meta.defaultHotkey),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
for (const binding of bindings) {
|
|
||||||
if (matchesEvent(binding.keys, e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
void rpc("execute_action", binding.invocation);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { RpcEventSchema, RpcSchema } from "@yaakapp-internal/proxy-lib";
|
|
||||||
import { command, subscribe } from "./tauri";
|
|
||||||
|
|
||||||
export type Req<K extends keyof RpcSchema> = RpcSchema[K][0];
|
|
||||||
export type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
|
|
||||||
|
|
||||||
export async function rpc<K extends keyof RpcSchema>(cmd: K, payload: Req<K>): Promise<Res<K>> {
|
|
||||||
return command<Res<K>>("rpc", { cmd, payload });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subscribe to a backend event. Returns an unsubscribe function. */
|
|
||||||
export function listen<K extends keyof RpcEventSchema>(
|
|
||||||
event: K & string,
|
|
||||||
callback: (payload: RpcEventSchema[K]) => void,
|
|
||||||
): () => void {
|
|
||||||
return subscribe<RpcEventSchema[K]>(event, callback);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { createModelStore } from "@yaakapp-internal/model-store";
|
|
||||||
import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
|
|
||||||
|
|
||||||
type ProxyModels = {
|
|
||||||
http_exchange: HttpExchange;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
|
|
||||||
createModelStore<ProxyModels>(["http_exchange"]);
|
|
||||||
|
|
||||||
export const httpExchangesAtom = orderedListAtom("http_exchange", "createdAt", "desc");
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen as tauriListen } from "@tauri-apps/api/event";
|
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
import { type as tauriOsType } from "@tauri-apps/plugin-os";
|
|
||||||
|
|
||||||
/** Call a Tauri command. */
|
|
||||||
export function command<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
|
||||||
return invoke(cmd, args) as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subscribe to a Tauri event. Returns an unsubscribe function. */
|
|
||||||
export function subscribe<T>(event: string, callback: (payload: T) => void): () => void {
|
|
||||||
let unsub: (() => void) | null = null;
|
|
||||||
tauriListen<T>(event, (e) => callback(e.payload))
|
|
||||||
.then((fn) => {
|
|
||||||
unsub = fn;
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
return () => unsub?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show the current webview window. */
|
|
||||||
export function showWindow(): Promise<void> {
|
|
||||||
return getCurrentWebviewWindow().show();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the current OS type (e.g. "macos", "linux", "windows"). */
|
|
||||||
export function getOsType() {
|
|
||||||
return tauriOsType();
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
|
||||||
import {
|
|
||||||
applyThemeToDocument,
|
|
||||||
defaultDarkTheme,
|
|
||||||
defaultLightTheme,
|
|
||||||
getCSSAppearance,
|
|
||||||
platformFromUserAgent,
|
|
||||||
setPlatformOnDocument,
|
|
||||||
subscribeToPreferredAppearance,
|
|
||||||
type Appearance,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
import { showWindow } from "./tauri";
|
|
||||||
|
|
||||||
setPlatformOnDocument(platformFromUserAgent(navigator.userAgent));
|
|
||||||
|
|
||||||
// Apply a quick initial theme based on CSS media query
|
|
||||||
let preferredAppearance: Appearance = getCSSAppearance();
|
|
||||||
applyTheme(preferredAppearance);
|
|
||||||
|
|
||||||
// Then subscribe to accurate OS appearance detection and changes
|
|
||||||
subscribeToPreferredAppearance((a) => {
|
|
||||||
preferredAppearance = a;
|
|
||||||
applyTheme(preferredAppearance);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show window after initial theme is applied (window starts hidden to prevent flash)
|
|
||||||
showWindow().catch(console.error);
|
|
||||||
|
|
||||||
function applyTheme(appearance: Appearance) {
|
|
||||||
const theme = appearance === "dark" ? defaultDarkTheme : defaultLightTheme;
|
|
||||||
applyThemeToDocument(theme);
|
|
||||||
if (theme.base.surface != null) {
|
|
||||||
setWindowTheme(theme.base.surface);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
@apply w-full h-full overflow-hidden text-text bg-surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--font-family-interface: "";
|
|
||||||
--font-family-editor: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
font-variant-ligatures: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-platform="linux"] {
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
@apply bg-selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
:not(a),
|
|
||||||
:not(input):not(textarea),
|
|
||||||
:not(input):not(textarea)::after,
|
|
||||||
:not(input):not(textarea)::before {
|
|
||||||
@apply select-none cursor-default;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
&::placeholder {
|
|
||||||
@apply text-placeholder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
|
||||||
a[href] * {
|
|
||||||
@apply cursor-pointer !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
table th {
|
|
||||||
@apply text-left;
|
|
||||||
}
|
|
||||||
|
|
||||||
:not(iframe) {
|
|
||||||
&::-webkit-scrollbar,
|
|
||||||
&::-webkit-scrollbar-corner {
|
|
||||||
@apply w-[8px] h-[8px] bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
@apply bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
@apply bg-text-subtlest rounded-[4px] opacity-20;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
|
||||||
@apply opacity-40 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide-scrollbars {
|
|
||||||
&::-webkit-scrollbar-corner,
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
@apply hidden !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rtl {
|
|
||||||
direction: rtl;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
color-scheme: light dark;
|
|
||||||
--transition-duration: 100ms ease-in-out;
|
|
||||||
--color-white: 255 100% 100%;
|
|
||||||
--color-black: 255 0% 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { createStore, Provider } from "jotai";
|
|
||||||
import { LazyMotion, MotionConfig } from "motion/react";
|
|
||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { ProxyLayout } from "./components/ProxyLayout";
|
|
||||||
import { listen, rpc } from "./lib/rpc";
|
|
||||||
import { initHotkeys } from "./lib/hotkeys";
|
|
||||||
import { applyChange, dataAtom, replaceAll } from "./lib/store";
|
|
||||||
import "./main.css";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const jotaiStore = createStore();
|
|
||||||
|
|
||||||
// Load initial models from the database
|
|
||||||
void rpc("list_models", {}).then((res) => {
|
|
||||||
jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "http_exchange", res.httpExchanges));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register hotkeys from action metadata
|
|
||||||
void initHotkeys();
|
|
||||||
|
|
||||||
// Subscribe to model change events from the backend
|
|
||||||
void listen("model_write", (payload) => {
|
|
||||||
jotaiStore.set(dataAtom, (prev) =>
|
|
||||||
applyChange(prev, "http_exchange", payload.model, payload.change),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const motionFeatures = () => import("framer-motion").then((mod) => mod.domAnimation);
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
|
||||||
<StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Provider store={jotaiStore}>
|
|
||||||
<LazyMotion strict features={motionFeatures}>
|
|
||||||
<MotionConfig transition={{ duration: 0.1 }}>
|
|
||||||
<ProxyLayout />
|
|
||||||
</MotionConfig>
|
|
||||||
</LazyMotion>
|
|
||||||
</Provider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@yaakapp/yaak-proxy",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vp dev --force",
|
|
||||||
"build": "vp build",
|
|
||||||
"lint": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/react-query": "^5.90.5",
|
|
||||||
"@tauri-apps/api": "^2.11.0",
|
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
|
||||||
"@yaakapp-internal/model-store": "^1.0.0",
|
|
||||||
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
|
||||||
"@yaakapp-internal/theme": "^1.0.0",
|
|
||||||
"@yaakapp-internal/ui": "^1.0.0",
|
|
||||||
"classnames": "^2.5.1",
|
|
||||||
"jotai": "^2.18.0",
|
|
||||||
"jotai-family": "^1.0.1",
|
|
||||||
"motion": "^12.4.7",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
|
||||||
"@types/babel__core": "^7.20.5",
|
|
||||||
"@types/react": "^19.2.0",
|
|
||||||
"@types/react-dom": "^19.2.0",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
|
|
||||||
"vite-plus": "^0.1.20"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: [
|
|
||||||
require("@tailwindcss/nesting")(require("postcss-nesting")),
|
|
||||||
require("tailwindcss"),
|
|
||||||
require("autoprefixer"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
const sharedConfig = require("@yaakapp-internal/tailwind-config");
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
...sharedConfig,
|
|
||||||
content: ["./**/*.{html,ts,tsx}", "../../packages/ui/src/**/*.{ts,tsx}"],
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite-plus/client" />
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
build: {
|
|
||||||
outDir: "../../dist/apps/yaak-proxy",
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
clearScreen: false,
|
|
||||||
server: {
|
|
||||||
port: parseInt(process.env.YAAK_PROXY_DEV_PORT ?? "2420", 10),
|
|
||||||
strictPort: true,
|
|
||||||
},
|
|
||||||
envPrefix: ["VITE_", "TAURI_"],
|
|
||||||
});
|
|
||||||
@@ -6,17 +6,17 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_models::blob_manager::BlobManager;
|
use yaak_models::blob_manager::BlobManager;
|
||||||
use yaak_models::client_db::ClientDb;
|
use yaak_models::db_context::DbContext;
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_plugins::events::PluginContext;
|
use yaak_plugins::events::PluginContext;
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
|
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
"/../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
"/../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs"
|
||||||
));
|
));
|
||||||
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
|
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
|
||||||
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app-client/vendored/plugins");
|
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app/vendored/plugins");
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct CliExecutionContext {
|
pub struct CliExecutionContext {
|
||||||
@@ -108,7 +108,7 @@ impl CliContext {
|
|||||||
&self.data_dir
|
&self.data_dir
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn db(&self) -> ClientDb<'_> {
|
pub fn db(&self) -> DbContext<'_> {
|
||||||
self.query_manager.connect()
|
self.query_manager.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use yaak::plugin_events::{
|
|||||||
use yaak::render::{render_grpc_request, render_http_request};
|
use yaak::render::{render_grpc_request, render_http_request};
|
||||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
|
||||||
use yaak_models::blob_manager::BlobManager;
|
use yaak_models::blob_manager::BlobManager;
|
||||||
use yaak_models::models::Environment;
|
use yaak_models::models::Environment;
|
||||||
use yaak_models::queries::any_request::AnyRequest;
|
use yaak_models::queries::any_request::AnyRequest;
|
||||||
@@ -473,7 +472,7 @@ async fn build_plugin_reply(
|
|||||||
let names = cookie_jar
|
let names = cookie_jar
|
||||||
.cookies
|
.cookies
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| c.name)
|
.filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
||||||
@@ -497,8 +496,10 @@ async fn build_plugin_reply(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let value =
|
let value = cookie_jar.cookies.into_iter().find_map(|c| {
|
||||||
get_cookie_value_from_jar(cookie_jar.cookies, &req.name, req.domain.as_deref());
|
let (name, value) = parse_cookie_name_value(&c.raw_cookie)?;
|
||||||
|
if name == req.name { Some(value) } else { None }
|
||||||
|
});
|
||||||
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value }))
|
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value }))
|
||||||
}
|
}
|
||||||
HostRequest::WindowInfo(req) => {
|
HostRequest::WindowInfo(req) => {
|
||||||
@@ -531,6 +532,13 @@ async fn render_json_value_for_cli<T: TemplateCallback>(
|
|||||||
render_json_value_raw(value, vars, cb, opt).await
|
render_json_value_raw(value, vars, cb, opt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
|
||||||
|
let first_part = raw_cookie.split(';').next()?.trim();
|
||||||
|
let (name, value) = first_part.split_once('=')?;
|
||||||
|
Some((name.trim().to_string(), value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
|
fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
|
||||||
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
|
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
|
||||||
clipboard.set_text(text.to_string()).map_err(|e| e.to_string())
|
clipboard.set_text(text.to_string()).map_err(|e| e.to_string())
|
||||||
|
|||||||
@@ -28,30 +28,8 @@ impl TestHttpServer {
|
|||||||
match listener.accept() {
|
match listener.accept() {
|
||||||
Ok((mut stream, _)) => {
|
Ok((mut stream, _)) => {
|
||||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
||||||
let mut request = Vec::new();
|
|
||||||
let mut request_buf = [0u8; 4096];
|
let mut request_buf = [0u8; 4096];
|
||||||
loop {
|
let _ = stream.read(&mut request_buf);
|
||||||
match stream.read(&mut request_buf) {
|
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
request.extend_from_slice(&request_buf[..n]);
|
|
||||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err)
|
|
||||||
if err.kind() == std::io::ErrorKind::WouldBlock
|
|
||||||
|| err.kind() == std::io::ErrorKind::TimedOut =>
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
@@ -60,6 +38,7 @@ impl TestHttpServer {
|
|||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.write_all(&body_bytes);
|
let _ = stream.write_all(&body_bytes);
|
||||||
let _ = stream.flush();
|
let _ = stream.flush();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
thread::sleep(Duration::from_millis(10));
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
|||||||
@@ -7,6 +7,34 @@ use tempfile::TempDir;
|
|||||||
use yaak_models::models::HttpRequest;
|
use yaak_models::models::HttpRequest;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_level_send_workspace_sends_http_requests_and_prints_summary() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
seed_workspace(data_dir, "wk_test");
|
||||||
|
|
||||||
|
let server = TestHttpServer::spawn_ok("workspace bulk send");
|
||||||
|
let request = HttpRequest {
|
||||||
|
id: "rq_workspace_send".to_string(),
|
||||||
|
workspace_id: "wk_test".to_string(),
|
||||||
|
name: "Workspace Send".to_string(),
|
||||||
|
method: "GET".to_string(),
|
||||||
|
url: server.url.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
query_manager(data_dir)
|
||||||
|
.connect()
|
||||||
|
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||||
|
.expect("Failed to seed workspace request");
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args(["send", "wk_test"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("workspace bulk send"))
|
||||||
|
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
|
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "yaak-proxy-lib"
|
|
||||||
version = "0.0.0"
|
|
||||||
edition = "2024"
|
|
||||||
authors = ["Gregory Schier"]
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
|
||||||
log = { workspace = true }
|
|
||||||
include_dir = "0.7"
|
|
||||||
r2d2 = "0.8.10"
|
|
||||||
r2d2_sqlite = "0.25.0"
|
|
||||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
|
||||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
|
||||||
yaak-database = { workspace = true }
|
|
||||||
yaak-proxy = { workspace = true }
|
|
||||||
yaak-rpc = { workspace = true }
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" };
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { ModelChangeEvent } from "./ModelChangeEvent";
|
|
||||||
|
|
||||||
export type HttpExchange = { id: string, createdAt: string, updatedAt: string, url: string, method: string, reqHeaders: Array<ProxyHeader>, reqBody: Array<number> | null, resStatus: number | null, resHeaders: Array<ProxyHeader>, resBody: Array<number> | null, error: string | null, };
|
|
||||||
|
|
||||||
export type ModelPayload = { model: HttpExchange, change: ModelChangeEvent, };
|
|
||||||
|
|
||||||
export type ProxyHeader = { name: string, value: string, };
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { HttpExchange, ModelPayload } from "./gen_models";
|
|
||||||
|
|
||||||
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
|
|
||||||
|
|
||||||
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
|
|
||||||
|
|
||||||
export type GetProxyStateRequest = Record<string, never>;
|
|
||||||
|
|
||||||
export type GetProxyStateResponse = { state: ProxyState, };
|
|
||||||
|
|
||||||
export type GlobalAction = "proxy_start" | "proxy_stop";
|
|
||||||
|
|
||||||
export type ListActionsRequest = Record<string, never>;
|
|
||||||
|
|
||||||
export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, };
|
|
||||||
|
|
||||||
export type ListModelsRequest = Record<string, never>;
|
|
||||||
|
|
||||||
export type ListModelsResponse = { httpExchanges: Array<HttpExchange>, };
|
|
||||||
|
|
||||||
export type ProxyState = "running" | "stopped";
|
|
||||||
|
|
||||||
export type ProxyStatePayload = { state: ProxyState, };
|
|
||||||
|
|
||||||
export type RpcEventSchema = { model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
|
||||||
|
|
||||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], };
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
export * from "./gen_rpc";
|
|
||||||
export * from "./gen_models";
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE http_exchanges
|
|
||||||
(
|
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
url TEXT NOT NULL DEFAULT '',
|
|
||||||
method TEXT NOT NULL DEFAULT '',
|
|
||||||
req_headers TEXT NOT NULL DEFAULT '[]',
|
|
||||||
req_body BLOB,
|
|
||||||
res_status INTEGER,
|
|
||||||
res_headers TEXT NOT NULL DEFAULT '[]',
|
|
||||||
res_body BLOB,
|
|
||||||
error TEXT
|
|
||||||
);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use ts_rs::TS;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub enum GlobalAction {
|
|
||||||
ProxyStart,
|
|
||||||
ProxyStop,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(tag = "scope", rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub enum ActionInvocation {
|
|
||||||
Global { action: GlobalAction },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct ActionMetadata {
|
|
||||||
pub label: String,
|
|
||||||
pub default_hotkey: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_hotkey(mac: &str, other: &str) -> Option<String> {
|
|
||||||
if cfg!(target_os = "macos") { Some(mac.into()) } else { Some(other.into()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All global actions with their metadata, used by `list_actions` RPC.
|
|
||||||
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
|
|
||||||
vec![
|
|
||||||
(
|
|
||||||
ActionInvocation::Global { action: GlobalAction::ProxyStart },
|
|
||||||
ActionMetadata {
|
|
||||||
label: "Start Proxy".into(),
|
|
||||||
default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
ActionInvocation::Global { action: GlobalAction::ProxyStop },
|
|
||||||
ActionMetadata {
|
|
||||||
label: "Stop Proxy".into(),
|
|
||||||
default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
use include_dir::{Dir, include_dir};
|
|
||||||
use r2d2::Pool;
|
|
||||||
use r2d2_sqlite::SqliteConnectionManager;
|
|
||||||
use std::path::Path;
|
|
||||||
use yaak_database::{ConnectionOrTx, DbContext, run_migrations};
|
|
||||||
|
|
||||||
static MIGRATIONS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/migrations");
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ProxyQueryManager {
|
|
||||||
pool: Pool<SqliteConnectionManager>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProxyQueryManager {
|
|
||||||
pub fn new(db_path: &Path) -> Self {
|
|
||||||
let manager = SqliteConnectionManager::file(db_path);
|
|
||||||
let pool =
|
|
||||||
Pool::builder().max_size(5).build(manager).expect("Failed to create proxy DB pool");
|
|
||||||
run_migrations(&pool, &MIGRATIONS).expect("Failed to run proxy DB migrations");
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_conn<F, T>(&self, func: F) -> T
|
|
||||||
where
|
|
||||||
F: FnOnce(&DbContext) -> T,
|
|
||||||
{
|
|
||||||
let conn = self.pool.get().expect("Failed to get proxy DB connection");
|
|
||||||
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
|
|
||||||
func(&ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
pub mod actions;
|
|
||||||
pub mod db;
|
|
||||||
pub mod models;
|
|
||||||
|
|
||||||
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
|
||||||
use crate::db::ProxyQueryManager;
|
|
||||||
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
|
|
||||||
use log::warn;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use ts_rs::TS;
|
|
||||||
use yaak_database::{ModelChangeEvent, UpdateSource};
|
|
||||||
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
|
|
||||||
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
|
|
||||||
|
|
||||||
// -- Context --
|
|
||||||
|
|
||||||
pub struct ProxyCtx {
|
|
||||||
handle: Mutex<Option<ProxyHandle>>,
|
|
||||||
pub db: ProxyQueryManager,
|
|
||||||
pub events: RpcEventEmitter,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProxyCtx {
|
|
||||||
pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self {
|
|
||||||
Self { handle: Mutex::new(None), db: ProxyQueryManager::new(db_path), events }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Proxy state --
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub enum ProxyState {
|
|
||||||
Running,
|
|
||||||
Stopped,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct ProxyStatePayload {
|
|
||||||
pub state: ProxyState,
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Request/response types --
|
|
||||||
|
|
||||||
#[derive(Deserialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct ListActionsRequest {}
|
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct ListActionsResponse {
|
|
||||||
pub actions: Vec<(ActionInvocation, ActionMetadata)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct ListModelsRequest {}
|
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ListModelsResponse {
|
|
||||||
pub http_exchanges: Vec<HttpExchange>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct GetProxyStateRequest {}
|
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct GetProxyStateResponse {
|
|
||||||
pub state: ProxyState,
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Handlers --
|
|
||||||
|
|
||||||
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
|
||||||
match invocation {
|
|
||||||
ActionInvocation::Global { action } => match action {
|
|
||||||
GlobalAction::ProxyStart => {
|
|
||||||
let mut handle =
|
|
||||||
ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
|
||||||
|
|
||||||
if handle.is_some() {
|
|
||||||
return Ok(true); // already running
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut proxy_handle =
|
|
||||||
yaak_proxy::start_proxy(9090).map_err(|e| RpcError { message: e })?;
|
|
||||||
|
|
||||||
if let Some(event_rx) = proxy_handle.take_event_rx() {
|
|
||||||
let db = ctx.db.clone();
|
|
||||||
let events = ctx.events.clone();
|
|
||||||
std::thread::spawn(move || run_event_loop(event_rx, db, events));
|
|
||||||
}
|
|
||||||
|
|
||||||
*handle = Some(proxy_handle);
|
|
||||||
ctx.events
|
|
||||||
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
GlobalAction::ProxyStop => {
|
|
||||||
let mut handle =
|
|
||||||
ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
|
||||||
handle.take();
|
|
||||||
ctx.events
|
|
||||||
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped });
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_proxy_state(
|
|
||||||
ctx: &ProxyCtx,
|
|
||||||
_req: GetProxyStateRequest,
|
|
||||||
) -> Result<GetProxyStateResponse, RpcError> {
|
|
||||||
let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
|
||||||
let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
|
||||||
Ok(GetProxyStateResponse { state })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_actions(
|
|
||||||
_ctx: &ProxyCtx,
|
|
||||||
_req: ListActionsRequest,
|
|
||||||
) -> Result<ListActionsResponse, RpcError> {
|
|
||||||
Ok(ListActionsResponse { actions: crate::actions::all_global_actions() })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
|
|
||||||
ctx.db.with_conn(|db| {
|
|
||||||
Ok(ListModelsResponse {
|
|
||||||
http_exchanges: db
|
|
||||||
.find_all::<HttpExchange>()
|
|
||||||
.map_err(|e| RpcError { message: e.to_string() })?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Event loop --
|
|
||||||
|
|
||||||
fn run_event_loop(
|
|
||||||
rx: std::sync::mpsc::Receiver<ProxyEvent>,
|
|
||||||
db: ProxyQueryManager,
|
|
||||||
events: RpcEventEmitter,
|
|
||||||
) {
|
|
||||||
let mut in_flight: HashMap<u64, CapturedRequest> = HashMap::new();
|
|
||||||
|
|
||||||
while let Ok(event) = rx.recv() {
|
|
||||||
match event {
|
|
||||||
ProxyEvent::RequestStart { id, method, url, http_version } => {
|
|
||||||
in_flight.insert(
|
|
||||||
id,
|
|
||||||
CapturedRequest {
|
|
||||||
id,
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
http_version,
|
|
||||||
status: None,
|
|
||||||
elapsed_ms: None,
|
|
||||||
remote_http_version: None,
|
|
||||||
request_headers: vec![],
|
|
||||||
request_body: None,
|
|
||||||
response_headers: vec![],
|
|
||||||
response_body: None,
|
|
||||||
response_body_size: 0,
|
|
||||||
state: RequestState::Sending,
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ProxyEvent::RequestHeader { id, name, value } => {
|
|
||||||
if let Some(r) = in_flight.get_mut(&id) {
|
|
||||||
r.request_headers.push((name, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProxyEvent::RequestBody { id, body } => {
|
|
||||||
if let Some(r) = in_flight.get_mut(&id) {
|
|
||||||
r.request_body = Some(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProxyEvent::ResponseStart { id, status, http_version, elapsed_ms } => {
|
|
||||||
if let Some(r) = in_flight.get_mut(&id) {
|
|
||||||
r.status = Some(status);
|
|
||||||
r.remote_http_version = Some(http_version);
|
|
||||||
r.elapsed_ms = Some(elapsed_ms);
|
|
||||||
r.state = RequestState::Receiving;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProxyEvent::ResponseHeader { id, name, value } => {
|
|
||||||
if let Some(r) = in_flight.get_mut(&id) {
|
|
||||||
r.response_headers.push((name, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProxyEvent::ResponseBodyChunk { .. } => {
|
|
||||||
// Progress only — no action needed
|
|
||||||
}
|
|
||||||
ProxyEvent::ResponseBodyComplete { id, body, size, elapsed_ms } => {
|
|
||||||
if let Some(mut r) = in_flight.remove(&id) {
|
|
||||||
r.response_body = body;
|
|
||||||
r.response_body_size = size;
|
|
||||||
r.elapsed_ms = r.elapsed_ms.or(Some(elapsed_ms));
|
|
||||||
r.state = RequestState::Complete;
|
|
||||||
write_entry(&db, &events, &r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProxyEvent::Error { id, error } => {
|
|
||||||
if let Some(mut r) = in_flight.remove(&id) {
|
|
||||||
r.error = Some(error);
|
|
||||||
r.state = RequestState::Error;
|
|
||||||
write_entry(&db, &events, &r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedRequest) {
|
|
||||||
let entry = HttpExchange {
|
|
||||||
url: r.url.clone(),
|
|
||||||
method: r.method.clone(),
|
|
||||||
req_headers: r
|
|
||||||
.request_headers
|
|
||||||
.iter()
|
|
||||||
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
|
|
||||||
.collect(),
|
|
||||||
req_body: r.request_body.clone(),
|
|
||||||
res_status: r.status.map(|s| s as i32),
|
|
||||||
res_headers: r
|
|
||||||
.response_headers
|
|
||||||
.iter()
|
|
||||||
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
|
|
||||||
.collect(),
|
|
||||||
res_body: r.response_body.clone(),
|
|
||||||
error: r.error.clone(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
db.with_conn(|ctx| match ctx.upsert(&entry, &UpdateSource::Background) {
|
|
||||||
Ok((saved, created)) => {
|
|
||||||
events.emit(
|
|
||||||
"model_write",
|
|
||||||
&ModelPayload { model: saved, change: ModelChangeEvent::Upsert { created } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to write proxy entry: {e}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Router + Schema --
|
|
||||||
|
|
||||||
define_rpc! {
|
|
||||||
ProxyCtx;
|
|
||||||
commands {
|
|
||||||
execute_action(ActionInvocation) -> bool,
|
|
||||||
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
|
||||||
list_actions(ListActionsRequest) -> ListActionsResponse,
|
|
||||||
list_models(ListModelsRequest) -> ListModelsResponse,
|
|
||||||
}
|
|
||||||
events {
|
|
||||||
model_write(ModelPayload),
|
|
||||||
proxy_state_changed(ProxyStatePayload),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
use chrono::NaiveDateTime;
|
|
||||||
use rusqlite::Row;
|
|
||||||
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use ts_rs::TS;
|
|
||||||
use yaak_database::{
|
|
||||||
ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id,
|
|
||||||
upsert_date,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
|
||||||
pub struct ProxyHeader {
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
|
||||||
#[enum_def(table_name = "http_exchanges")]
|
|
||||||
pub struct HttpExchange {
|
|
||||||
pub id: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub url: String,
|
|
||||||
pub method: String,
|
|
||||||
pub req_headers: Vec<ProxyHeader>,
|
|
||||||
pub req_body: Option<Vec<u8>>,
|
|
||||||
pub res_status: Option<i32>,
|
|
||||||
pub res_headers: Vec<ProxyHeader>,
|
|
||||||
pub res_body: Option<Vec<u8>>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
|
||||||
pub struct ModelPayload {
|
|
||||||
pub model: HttpExchange,
|
|
||||||
pub change: ModelChangeEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpsertModelInfo for HttpExchange {
|
|
||||||
fn table_name() -> impl IntoTableRef + IntoIden {
|
|
||||||
HttpExchangeIden::Table
|
|
||||||
}
|
|
||||||
|
|
||||||
fn id_column() -> impl IntoIden + Eq + Clone {
|
|
||||||
HttpExchangeIden::Id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_id() -> String {
|
|
||||||
generate_prefixed_id("he")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn order_by() -> (impl IntoColumnRef, Order) {
|
|
||||||
(HttpExchangeIden::CreatedAt, Order::Desc)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_id(&self) -> String {
|
|
||||||
self.id.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_values(
|
|
||||||
self,
|
|
||||||
source: &UpdateSource,
|
|
||||||
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
|
||||||
use HttpExchangeIden::*;
|
|
||||||
Ok(vec![
|
|
||||||
(CreatedAt, upsert_date(source, self.created_at)),
|
|
||||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
|
||||||
(Url, self.url.into()),
|
|
||||||
(Method, self.method.into()),
|
|
||||||
(ReqHeaders, serde_json::to_string(&self.req_headers)?.into()),
|
|
||||||
(ReqBody, self.req_body.into()),
|
|
||||||
(ResStatus, self.res_status.into()),
|
|
||||||
(ResHeaders, serde_json::to_string(&self.res_headers)?.into()),
|
|
||||||
(ResBody, self.res_body.into()),
|
|
||||||
(Error, self.error.into()),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_columns() -> Vec<impl IntoIden> {
|
|
||||||
vec![
|
|
||||||
HttpExchangeIden::UpdatedAt,
|
|
||||||
HttpExchangeIden::Url,
|
|
||||||
HttpExchangeIden::Method,
|
|
||||||
HttpExchangeIden::ReqHeaders,
|
|
||||||
HttpExchangeIden::ReqBody,
|
|
||||||
HttpExchangeIden::ResStatus,
|
|
||||||
HttpExchangeIden::ResHeaders,
|
|
||||||
HttpExchangeIden::ResBody,
|
|
||||||
HttpExchangeIden::Error,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_row(r: &Row) -> rusqlite::Result<Self>
|
|
||||||
where
|
|
||||||
Self: Sized,
|
|
||||||
{
|
|
||||||
let req_headers: String = r.get("req_headers")?;
|
|
||||||
let res_headers: String = r.get("res_headers")?;
|
|
||||||
Ok(Self {
|
|
||||||
id: r.get("id")?,
|
|
||||||
created_at: r.get("created_at")?,
|
|
||||||
updated_at: r.get("updated_at")?,
|
|
||||||
url: r.get("url")?,
|
|
||||||
method: r.get("method")?,
|
|
||||||
req_headers: serde_json::from_str(&req_headers).unwrap_or_default(),
|
|
||||||
req_body: r.get("req_body")?,
|
|
||||||
res_status: r.get("res_status")?,
|
|
||||||
res_headers: serde_json::from_str(&res_headers).unwrap_or_default(),
|
|
||||||
res_body: r.get("res_body")?,
|
|
||||||
error: r.get("error")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@yaakapp-internal/tauri-client",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "bindings/index.ts"
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
use crate::error::{Error, Result};
|
|
||||||
use chrono::Utc;
|
|
||||||
use log::{debug, error, warn};
|
|
||||||
use notify::Watcher;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tauri::ipc::Channel;
|
|
||||||
use tauri::{AppHandle, Listener, Runtime};
|
|
||||||
use tokio::select;
|
|
||||||
use tokio::sync::watch;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
use ts_rs::TS;
|
|
||||||
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
|
|
||||||
|
|
||||||
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "index.ts")]
|
|
||||||
pub(crate) struct GitWatchResult {
|
|
||||||
unlisten_event: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn watch_git_worktree_status<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
dir: &Path,
|
|
||||||
channel: Channel<GitWorktreeStatus>,
|
|
||||||
) -> Result<GitWatchResult> {
|
|
||||||
let paths = git_repository_paths(dir)?;
|
|
||||||
let repo_dir = dir.to_path_buf();
|
|
||||||
let workdir = paths.workdir;
|
|
||||||
let gitdir = paths.gitdir;
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
|
|
||||||
let mut watcher = notify::recommended_watcher(tx)
|
|
||||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
|
|
||||||
|
|
||||||
watcher
|
|
||||||
.watch(&workdir, notify::RecursiveMode::Recursive)
|
|
||||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?;
|
|
||||||
if gitdir != workdir {
|
|
||||||
watcher
|
|
||||||
.watch(&gitdir, notify::RecursiveMode::Recursive)
|
|
||||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
for res in rx {
|
|
||||||
if async_tx.blocking_send(res).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let (cancel_tx, cancel_rx) = watch::channel(());
|
|
||||||
let mut cancel_rx = cancel_rx;
|
|
||||||
send_worktree_status(&repo_dir, &channel);
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let _watcher = watcher;
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
Some(event_res) = async_rx.recv() => {
|
|
||||||
handle_git_watch_event(
|
|
||||||
event_res,
|
|
||||||
&mut async_rx,
|
|
||||||
&repo_dir,
|
|
||||||
&workdir,
|
|
||||||
&gitdir,
|
|
||||||
&channel,
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
_ = cancel_rx.changed() => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let app_handle_inner = app_handle.clone();
|
|
||||||
let unlisten_event = format!("git-watch-unlisten-{}", Utc::now().timestamp_millis());
|
|
||||||
app_handle.listen_any(unlisten_event.clone(), move |event| {
|
|
||||||
app_handle_inner.unlisten(event.id());
|
|
||||||
if let Err(e) = cancel_tx.send(()) {
|
|
||||||
warn!("Failed to send git watch cancel signal {e:?}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(GitWatchResult { unlisten_event })
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_git_watch_event(
|
|
||||||
event_res: notify::Result<notify::Event>,
|
|
||||||
async_rx: &mut tokio::sync::mpsc::Receiver<notify::Result<notify::Event>>,
|
|
||||||
repo_dir: &Path,
|
|
||||||
workdir: &Path,
|
|
||||||
gitdir: &Path,
|
|
||||||
channel: &Channel<GitWorktreeStatus>,
|
|
||||||
) {
|
|
||||||
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send_worktree_status(repo_dir, channel);
|
|
||||||
|
|
||||||
let settle_window = sleep(GIT_STATUS_COALESCE_WINDOW);
|
|
||||||
tokio::pin!(settle_window);
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
Some(event_res) = async_rx.recv() => {
|
|
||||||
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir);
|
|
||||||
}
|
|
||||||
_ = &mut settle_window => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
send_worktree_status(repo_dir, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_relevant_git_watch_event(
|
|
||||||
event_res: notify::Result<notify::Event>,
|
|
||||||
repo_dir: &Path,
|
|
||||||
workdir: &Path,
|
|
||||||
gitdir: &Path,
|
|
||||||
) -> bool {
|
|
||||||
let event = match event_res {
|
|
||||||
Ok(event) => event,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Git watch error: {:?}", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for path in event.paths {
|
|
||||||
if path.strip_prefix(gitdir).is_ok() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(rela_path) = path.strip_prefix(workdir) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
match git_path_is_ignored(repo_dir, rela_path) {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => return true,
|
|
||||||
Err(e) => {
|
|
||||||
debug!("Failed to check Git ignore status for {:?}: {e}", rela_path);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_worktree_status(repo_dir: &Path, channel: &Channel<GitWorktreeStatus>) {
|
|
||||||
match git_worktree_status(repo_dir) {
|
|
||||||
Ok(status) => {
|
|
||||||
if let Err(e) = channel.send(status) {
|
|
||||||
warn!("Failed to send git worktree status: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to get git worktree status: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
tauri_app_client_lib::run();
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Generated by Cargo
|
|
||||||
# will have compiled files and executables
|
|
||||||
target/
|
|
||||||
|
|
||||||
gen/*
|
|
||||||
|
|
||||||
**/permissions/autogenerated
|
|
||||||
**/permissions/schemas
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "yaak-app-proxy"
|
|
||||||
version = "0.0.0"
|
|
||||||
edition = "2024"
|
|
||||||
authors = ["Gregory Schier"]
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "tauri_app_proxy_lib"
|
|
||||||
crate-type = ["staticlib", "cdylib", "lib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "2.6.1", features = [] }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
log = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
tauri = { workspace = true, features = ["devtools"] }
|
|
||||||
tauri-plugin-os = "2.3.2"
|
|
||||||
yaak-mac-window = { workspace = true }
|
|
||||||
yaak-proxy-lib = { workspace = true }
|
|
||||||
yaak-rpc = { workspace = true }
|
|
||||||
yaak-window = { workspace = true }
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"identifier": "default",
|
|
||||||
"description": "Default capabilities for the Yaak Proxy app",
|
|
||||||
"windows": ["*"],
|
|
||||||
"permissions": [
|
|
||||||
"core:default",
|
|
||||||
"os:allow-os-type",
|
|
||||||
"core:window:allow-close",
|
|
||||||
"core:window:allow-is-fullscreen",
|
|
||||||
"core:window:allow-is-maximized",
|
|
||||||
"core:window:allow-maximize",
|
|
||||||
"core:window:allow-minimize",
|
|
||||||
"core:window:allow-show",
|
|
||||||
"core:window:allow-start-dragging",
|
|
||||||
"core:window:allow-unmaximize",
|
|
||||||
"yaak-mac-window:default"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@yaakapp-internal/tauri-proxy",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "bindings/index.ts"
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
use log::{error, info, warn};
|
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
|
|
||||||
use yaak_proxy_lib::ProxyCtx;
|
|
||||||
use yaak_rpc::{RpcEventEmitter, RpcRouter};
|
|
||||||
use yaak_window::window::CreateWindowConfig;
|
|
||||||
|
|
||||||
mod window_menu;
|
|
||||||
|
|
||||||
fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) {
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
let menu = match window_menu::app_menu(win.app_handle()) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to create menu: {e:?}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// This causes the window to not be clickable (in AppImage), so disable on Linux
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
win.app_handle().set_menu(menu).expect("Failed to set app menu");
|
|
||||||
|
|
||||||
let webview_window = win.clone();
|
|
||||||
win.on_menu_event(move |w, event| {
|
|
||||||
if !w.is_focused().unwrap() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let event_id = event.id().0.as_str();
|
|
||||||
match event_id {
|
|
||||||
"hacked_quit" => {
|
|
||||||
w.webview_windows().iter().for_each(|(_, w)| {
|
|
||||||
info!("Closing window {}", w.label());
|
|
||||||
let _ = w.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"dev.refresh" => webview_window.eval("location.reload()").unwrap(),
|
|
||||||
"dev.toggle_devtools" => {
|
|
||||||
if webview_window.is_devtools_open() {
|
|
||||||
webview_window.close_devtools();
|
|
||||||
} else {
|
|
||||||
webview_window.open_devtools();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn rpc(
|
|
||||||
router: State<'_, RpcRouter<ProxyCtx>>,
|
|
||||||
ctx: State<'_, ProxyCtx>,
|
|
||||||
cmd: String,
|
|
||||||
payload: serde_json::Value,
|
|
||||||
) -> Result<serde_json::Value, String> {
|
|
||||||
router.dispatch(&cmd, payload, &ctx).map_err(|e| e.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run() {
|
|
||||||
tauri::Builder::default()
|
|
||||||
.plugin(tauri_plugin_os::init())
|
|
||||||
.plugin(yaak_mac_window::init())
|
|
||||||
.setup(|app| {
|
|
||||||
let data_dir = app.path().app_data_dir().expect("no app data dir");
|
|
||||||
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
|
|
||||||
|
|
||||||
let (emitter, event_rx) = RpcEventEmitter::new();
|
|
||||||
app.manage(ProxyCtx::new(&data_dir.join("proxy.db"), emitter));
|
|
||||||
app.manage(yaak_proxy_lib::build_router());
|
|
||||||
|
|
||||||
// Drain RPC events and forward as Tauri events
|
|
||||||
let app_handle = app.handle().clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
for event in event_rx {
|
|
||||||
if let Err(e) = app_handle.emit(event.event, event.payload) {
|
|
||||||
error!("Failed to emit RPC event: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.invoke_handler(tauri::generate_handler![rpc])
|
|
||||||
.build(tauri::generate_context!())
|
|
||||||
.expect("error while building yaak proxy tauri application")
|
|
||||||
.run(|app_handle, event| {
|
|
||||||
if let RunEvent::Ready = event {
|
|
||||||
let config = CreateWindowConfig {
|
|
||||||
url: "/",
|
|
||||||
label: "main_0",
|
|
||||||
title: "Yaak Proxy",
|
|
||||||
inner_size: Some((1000.0, 700.0)),
|
|
||||||
visible: false,
|
|
||||||
hide_titlebar: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
match yaak_window::window::create_window(app_handle, config) {
|
|
||||||
Ok(win) => setup_window_menu(&win),
|
|
||||||
Err(e) => error!("Failed to create proxy window: {e:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
pub use tauri::AppHandle;
|
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri::menu::{
|
|
||||||
AboutMetadata, HELP_SUBMENU_ID, Menu, MenuItemBuilder, PredefinedMenuItem, Submenu,
|
|
||||||
WINDOW_SUBMENU_ID,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
|
||||||
let pkg_info = app_handle.package_info();
|
|
||||||
let config = app_handle.config();
|
|
||||||
let about_metadata = AboutMetadata {
|
|
||||||
name: Some(pkg_info.name.clone()),
|
|
||||||
version: Some(pkg_info.version.to_string()),
|
|
||||||
copyright: config.bundle.copyright.clone(),
|
|
||||||
authors: config.bundle.publisher.clone().map(|p| vec![p]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let window_menu = Submenu::with_id_and_items(
|
|
||||||
app_handle,
|
|
||||||
WINDOW_SUBMENU_ID,
|
|
||||||
"Window",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&PredefinedMenuItem::minimize(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::maximize(app_handle, None)?,
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
&PredefinedMenuItem::separator(app_handle)?,
|
|
||||||
&PredefinedMenuItem::close_window(app_handle, None)?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
window_menu.set_as_windows_menu_for_nsapp()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let help_menu = Submenu::with_id_and_items(
|
|
||||||
app_handle,
|
|
||||||
HELP_SUBMENU_ID,
|
|
||||||
"Help",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
&PredefinedMenuItem::about(app_handle, None, Some(about_metadata.clone()))?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
help_menu.set_as_windows_menu_for_nsapp()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let menu = Menu::with_items(
|
|
||||||
app_handle,
|
|
||||||
&[
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
&Submenu::with_items(
|
|
||||||
app_handle,
|
|
||||||
pkg_info.name.clone(),
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?,
|
|
||||||
&PredefinedMenuItem::separator(app_handle)?,
|
|
||||||
&PredefinedMenuItem::services(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::separator(app_handle)?,
|
|
||||||
&PredefinedMenuItem::hide(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::hide_others(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::separator(app_handle)?,
|
|
||||||
&MenuItemBuilder::with_id(
|
|
||||||
"hacked_quit".to_string(),
|
|
||||||
format!("Quit {}", app_handle.package_info().name),
|
|
||||||
)
|
|
||||||
.accelerator("CmdOrCtrl+q")
|
|
||||||
.build(app_handle)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
#[cfg(not(any(
|
|
||||||
target_os = "linux",
|
|
||||||
target_os = "dragonfly",
|
|
||||||
target_os = "freebsd",
|
|
||||||
target_os = "netbsd",
|
|
||||||
target_os = "openbsd"
|
|
||||||
)))]
|
|
||||||
&Submenu::with_items(
|
|
||||||
app_handle,
|
|
||||||
"File",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&PredefinedMenuItem::close_window(app_handle, None)?,
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
&PredefinedMenuItem::quit(app_handle, None)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
&Submenu::with_items(
|
|
||||||
app_handle,
|
|
||||||
"Edit",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&PredefinedMenuItem::undo(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::redo(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::separator(app_handle)?,
|
|
||||||
&PredefinedMenuItem::cut(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::copy(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::paste(app_handle, None)?,
|
|
||||||
&PredefinedMenuItem::select_all(app_handle, None)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
&Submenu::with_items(
|
|
||||||
app_handle,
|
|
||||||
"View",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
&PredefinedMenuItem::fullscreen(app_handle, None)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
&window_menu,
|
|
||||||
&help_menu,
|
|
||||||
#[cfg(dev)]
|
|
||||||
&Submenu::with_items(
|
|
||||||
app_handle,
|
|
||||||
"Develop",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItemBuilder::with_id("dev.refresh".to_string(), "Refresh")
|
|
||||||
.accelerator("CmdOrCtrl+Shift+r")
|
|
||||||
.build(app_handle)?,
|
|
||||||
&MenuItemBuilder::with_id("dev.toggle_devtools".to_string(), "Open Devtools")
|
|
||||||
.accelerator("CmdOrCtrl+Option+i")
|
|
||||||
.build(app_handle)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(menu)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"productName": "Yaak Proxy",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"identifier": "app.yaak.proxy",
|
|
||||||
"build": {
|
|
||||||
"beforeBuildCommand": "npm --prefix ../.. run proxy:tauri-before-build",
|
|
||||||
"beforeDevCommand": "npm --prefix ../.. run proxy:tauri-before-dev",
|
|
||||||
"devUrl": "http://localhost:2420",
|
|
||||||
"frontendDist": "../../dist/apps/yaak-proxy"
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"withGlobalTauri": false,
|
|
||||||
"windows": []
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"icon": [
|
|
||||||
"../yaak-app-client/icons/release/32x32.png",
|
|
||||||
"../yaak-app-client/icons/release/128x128.png",
|
|
||||||
"../yaak-app-client/icons/release/128x128@2x.png",
|
|
||||||
"../yaak-app-client/icons/release/icon.icns",
|
|
||||||
"../yaak-app-client/icons/release/icon.ico"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"productName": "Yaak Proxy Dev",
|
|
||||||
"identifier": "app.yaak.proxy.dev",
|
|
||||||
"bundle": {
|
|
||||||
"icon": [
|
|
||||||
"../yaak-app-client/icons/dev/32x32.png",
|
|
||||||
"../yaak-app-client/icons/dev/128x128.png",
|
|
||||||
"../yaak-app-client/icons/dev/128x128@2x.png",
|
|
||||||
"../yaak-app-client/icons/dev/icon.icns",
|
|
||||||
"../yaak-app-client/icons/dev/icon.ico"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"build": {
|
|
||||||
"features": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Categories={{categories}}
|
|
||||||
Comment={{comment}}
|
|
||||||
Exec={{exec}}
|
|
||||||
Icon={{icon}}
|
|
||||||
Name={{name}}
|
|
||||||
StartupWMClass={{exec}}
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "yaak-app-client"
|
name = "yaak-app"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Gregory Schier"]
|
authors = ["Gregory Schier"]
|
||||||
@@ -7,7 +7,7 @@ publish = false
|
|||||||
|
|
||||||
# Produce a library for mobile support
|
# Produce a library for mobile support
|
||||||
[lib]
|
[lib]
|
||||||
name = "tauri_app_client_lib"
|
name = "tauri_app_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "lib"]
|
crate-type = ["staticlib", "cdylib", "lib"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
@@ -17,7 +17,7 @@ updater = []
|
|||||||
license = ["yaak-license"]
|
license = ["yaak-license"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.6.1", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
|
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
|
||||||
@@ -30,7 +30,6 @@ eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client
|
|||||||
http = { version = "1.2.0", default-features = false }
|
http = { version = "1.2.0", default-features = false }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
md5 = "0.8.0"
|
md5 = "0.8.0"
|
||||||
notify = "8.0.0"
|
|
||||||
pretty_graphql = "0.2"
|
pretty_graphql = "0.2"
|
||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.25.0"
|
r2d2_sqlite = "0.25.0"
|
||||||
@@ -50,15 +49,15 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json = { workspace = true, features = ["raw_value"] }
|
serde_json = { workspace = true, features = ["raw_value"] }
|
||||||
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
|
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
|
||||||
tauri-plugin-clipboard-manager = "2.3.2"
|
tauri-plugin-clipboard-manager = "2.3.2"
|
||||||
tauri-plugin-deep-link = "2.4.9"
|
tauri-plugin-deep-link = "2.4.5"
|
||||||
tauri-plugin-dialog = { workspace = true }
|
tauri-plugin-dialog = { workspace = true }
|
||||||
tauri-plugin-fs = "2.5.1"
|
tauri-plugin-fs = "2.4.4"
|
||||||
tauri-plugin-log = { version = "2.8.0", features = ["colored"] }
|
tauri-plugin-log = { version = "2.7.1", features = ["colored"] }
|
||||||
tauri-plugin-opener = "2.5.4"
|
tauri-plugin-opener = "2.5.2"
|
||||||
tauri-plugin-os = "2.3.2"
|
tauri-plugin-os = "2.3.2"
|
||||||
tauri-plugin-shell = { workspace = true }
|
tauri-plugin-shell = { workspace = true }
|
||||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
|
||||||
tauri-plugin-updater = "2.10.1"
|
tauri-plugin-updater = "2.9.0"
|
||||||
tauri-plugin-window-state = "2.4.1"
|
tauri-plugin-window-state = "2.4.1"
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync"] }
|
tokio = { workspace = true, features = ["sync"] }
|
||||||
@@ -86,5 +85,4 @@ yaak-sse = { workspace = true }
|
|||||||
yaak-sync = { workspace = true }
|
yaak-sync = { workspace = true }
|
||||||
yaak-templates = { workspace = true }
|
yaak-templates = { workspace = true }
|
||||||
yaak-tls = { workspace = true }
|
yaak-tls = { workspace = true }
|
||||||
yaak-window = { workspace = true }
|
|
||||||
yaak-ws = { workspace = true }
|
yaak-ws = { workspace = true }
|
||||||
Generated
-2
@@ -12,8 +12,6 @@ export type UpdateResponseAction = "install" | "skip";
|
|||||||
|
|
||||||
export type WatchResult = { unlistenEvent: string, };
|
export type WatchResult = { unlistenEvent: string, };
|
||||||
|
|
||||||
export type GitWatchResult = { unlistenEvent: string, };
|
|
||||||
|
|
||||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
||||||
|
|
||||||
export type YaakNotificationAction = { label: string, url: string, };
|
export type YaakNotificationAction = { label: string, url: string, };
|
||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user