Compare commits

..

14 Commits

Author SHA1 Message Date
Gregory Schier 5919fae739 Run oxfmt across repo, add format script and ignore config
Format all non-generated files with oxfmt via `vp fmt`. Add
.oxfmtignore to skip bindings/ and wasm-pack output. Add npm
format script and update DEVELOPMENT.md docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:52:11 -07:00
Gregory Schier a9cccb21b8 Use npm commands in DEVELOPMENT.md for contributor familiarity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:49:03 -07:00
Gregory Schier 1c0435e3ff Update DEVELOPMENT.md for Vite+ toolchain
Replace Biome references with Vite+ (oxlint/oxfmt), add vp as a
prerequisite, and document pre-commit hook and VS Code extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:48:21 -07:00
Gregory Schier a629a1fa79 Fix wasm-pack output for Vite 8/rolldown production builds
Rewrite generated yaak_templates.js to use Vite's ?init import style
instead of the ES Module Integration style that rolldown doesn't support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:02:12 -07:00
Gregory Schier dde8d61b4b Run bootstrap 2026-03-13 08:50:43 -07:00
Gregory Schier 08a64b6938 VSCode suggestion and CI 2026-03-13 08:44:52 -07:00
Gregory Schier 560c4667e4 More fixes 2026-03-13 08:32:40 -07:00
Gregory Schier 21f775741a More fixes 2026-03-13 08:29:36 -07:00
Gregory Schier 44a331929f Enable type-aware linting and replace biome-ignore with oxlint-disable
- Enable typeAware option and no-explicit-any (error) in vite.config.ts
- Ignore generated binding files from linting
- Convert all 96 biome-ignore comments to oxlint-disable equivalents
- Add suppression comments for 3 previously uncovered any usages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:24:45 -07:00
Gregory Schier 7670ab007f Fix lint errors 2026-03-13 08:12:21 -07:00
Gregory Schier be34dfe74a Fix no-base-to-string 2026-03-13 07:48:50 -07:00
Gregory Schier e4103f1a4a Fix some lint 2026-03-13 07:36:26 -07:00
Gregory Schier 7f4eedd630 Get lint running 2026-03-13 07:32:55 -07:00
Gregory Schier 49659a3da9 Migrate to Vite+ (vite-plus) unified toolchain
Replace Vite/Vitest with vite-plus, update WASM loading to native Vite 8
?init pattern, switch React compiler to @rolldown/plugin-babel, and
migrate git hooks from husky to vite-hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:23:11 -07:00
796 changed files with 6230 additions and 15854 deletions
+7 -7
View File
@@ -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-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)
```
@@ -16,7 +16,7 @@ crates-cli/ # CLI crate (yaak-cli)
### 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-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
4. Initialize managers in yaak-app's `.setup()` block
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()`
## Key Files
- `crates-tauri/yaak-app-client/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-client/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
- `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/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
- 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
+2 -2
View File
@@ -1,5 +1,5 @@
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true
crates/yaak-templates/pkg/* linguist-generated=true
+4 -7
View File
@@ -125,8 +125,8 @@ jobs:
security list-keychain -d user -s $KEYCHAIN_PATH
# 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-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.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || 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
env:
@@ -155,8 +155,7 @@ jobs:
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true
prerelease: true
projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
@@ -172,9 +171,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
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 ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
Pop-Location
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
+3 -3
View File
@@ -45,8 +45,8 @@ jobs:
with:
name: vendored-assets
path: |
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app-client/vendored/plugins
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app/vendored/plugins
if-no-files-found: error
build-binaries:
@@ -107,7 +107,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: vendored-assets
path: crates-tauri/yaak-app-client/vendored
path: crates-tauri/yaak-app/vendored
- name: Set CLI build version
shell: bash
+1 -2
View File
@@ -39,8 +39,7 @@ codebook.toml
target
# Per-worktree Tauri config (generated by post-checkout hook)
crates-tauri/yaak-app-client/tauri.worktree.conf.json
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
crates-tauri/yaak-app/tauri.worktree.conf.json
# Tauri auto-generated permission files
**/permissions/autogenerated
+1
View File
@@ -0,0 +1 @@
20
-1
View File
@@ -1,3 +1,2 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
-8
View File
@@ -1,8 +0,0 @@
{
"printWidth": 100,
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
]
}
-1
View File
@@ -1,2 +1 @@
vp lint
vp staged
Generated
+643 -566
View File
File diff suppressed because it is too large Load Diff
+5 -22
View File
@@ -2,9 +2,6 @@
resolver = "2"
members = [
"crates/yaak",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
@@ -20,19 +17,14 @@ members = [
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-app",
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
]
[workspace.dependencies]
@@ -47,18 +39,14 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
tauri = "2.11.1"
tauri-plugin = "2.6.1"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-shell = "2.3.5"
tauri = "2.9.5"
tauri-plugin = "2.5.2"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-shell = "2.3.3"
thiserror = "2.0.17"
tokio = "1.48.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
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
@@ -75,17 +63,12 @@ yaak-templates = { path = "crates/yaak-templates" }
yaak-tls = { path = "crates/yaak-tls" }
yaak-ws = { path = "crates/yaak-ws" }
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
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
strip = false
+1 -1
View File
@@ -1,6 +1,6 @@
<p align="center">
<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>
</p>
@@ -1,659 +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, useMemo, 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 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 = () => {
if (cookieJar == null || draftCookie == null) {
return;
}
let nextCookie = normalizeCookie(draftCookie);
if (nextCookie.name.trim().length === 0) {
showAlert({
id: "invalid-cookie-name",
title: "Invalid Cookie",
body: "Cookie name is required.",
});
return;
}
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.65}
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 }) => (
<div
style={style}
className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2"
>
<EventDetailHeader
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
copyText={isEditingCookie ? undefined : detailCookie.value}
actions={
isEditingCookie
? [
{
key: "save",
label: isCreatingCookie ? "Create" : "Save",
onClick: handleSaveCookie,
},
{
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} />
)}
</div>
)
}
/>
)}
</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
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
value={cookieDomainInputValue(cookie)}
placeholder="n/a"
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,
placeholder,
required,
value,
}: {
autoFocus?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
value: string;
}) {
return (
<input
autoFocus={autoFocus}
className={cookieInputClassName}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
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 cookieInputClassName = classNames(
"w-full min-w-0 rounded bg-transparent px-1 py-0.5",
"border border-transparent outline-none",
"hover:border-border-subtle focus:border-border-focus",
"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,
name: cookie.name.trim(),
path: cookie.path.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 JSON.stringify([cookie.name, cookieDomain(cookie), cookie.path]);
}
@@ -1,321 +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 { 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 ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingValidateCertificates?: ModelWithHttpSettings["settingValidateCertificates"];
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings =
model.model === "workspace" || model.model === "folder" || model.model === "http_request";
return (
<SettingsList className="space-y-8">
{supportsHttpSettings && (
<SettingsSection title={showSectionTitles ? "Requests" : null}>
<IntegerSettingRow
title="Request Timeout"
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
name="settingRequestTimeout"
setting={model.settingRequestTimeout}
inheritedValue={resolveInheritedValue(
ancestors,
"settingRequestTimeout",
model.settingRequestTimeout,
)}
onChange={(settingRequestTimeout) =>
patchHttpSettings(model, {
settingRequestTimeout,
})
}
/>
<BooleanSettingRow
title="Validate TLS certificates"
description="When disabled, skip validation of server certificates."
setting={model.settingValidateCertificates}
inheritedValue={resolveInheritedValue(
ancestors,
"settingValidateCertificates",
model.settingValidateCertificates,
)}
onChange={(settingValidateCertificates) =>
patchHttpSettings(model, {
settingValidateCertificates,
})
}
/>
<BooleanSettingRow
title="Follow redirects"
description="Follow HTTP redirects automatically."
setting={model.settingFollowRedirects}
inheritedValue={resolveInheritedValue(
ancestors,
"settingFollowRedirects",
model.settingFollowRedirects,
)}
onChange={(settingFollowRedirects) =>
patchHttpSettings(model, {
settingFollowRedirects,
})
}
/>
</SettingsSection>
)}
<SettingsSection title={supportsHttpSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
title="Automatically send cookies"
description="Attach matching cookies from the active cookie jar to outgoing requests."
setting={model.settingSendCookies}
inheritedValue={resolveInheritedValue(
ancestors,
"settingSendCookies",
model.settingSendCookies,
)}
onChange={(settingSendCookies) =>
patchCookieSettings(model, {
settingSendCookies,
})
}
/>
<BooleanSettingRow
title="Automatically store cookies"
description="Save cookies from Set-Cookie response headers to the active cookie jar."
setting={model.settingStoreCookies}
inheritedValue={resolveInheritedValue(
ancestors,
"settingStoreCookies",
model.settingStoreCookies,
)}
onChange={(settingStoreCookies) =>
patchCookieSettings(model, {
settingStoreCookies,
})
}
/>
</SettingsSection>
</SettingsList>
);
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [
model.settingSendCookies,
model.settingStoreCookies,
];
if (model.model === "workspace" || model.model === "folder" || model.model === "http_request") {
settings.push(
model.settingValidateCertificates,
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>);
return patchModel(model, patch as Partial<GrpcRequest>);
}
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 BooleanSettingRow({
description,
inheritedValue,
setting,
title,
onChange,
}: {
description: string;
inheritedValue: boolean;
setting: BooleanSetting;
title: string;
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={title}
description={description}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={title}
description={description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Checkbox
hideLabel
size="md"
title={title}
checked={value}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
description,
inheritedValue,
name,
setting,
title,
onChange,
}: {
description: string;
inheritedValue: number;
name: string;
setting: IntegerSetting;
title: string;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const showReset = overridden && value !== inheritedValue;
if (!inherited) {
return (
<SettingRowNumber
name={name}
title={title}
description={description}
value={value}
placeholder="0"
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={title}
description={description}
overridden={showReset}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<PlainInput
hideLabel
name={name}
label={title}
size="sm"
type="number"
placeholder="0"
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,193 +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 { 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="settingRequestTimeout"
title="Request Timeout"
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
placeholder="0"
required
validate={(value) => Number.parseInt(value, 10) >= 0}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey="settingValidateCertificates"
title="Validate TLS certificates"
description="When disabled, skip validation of server certificates."
/>
<ModelSettingRowBoolean
model={workspace}
modelKey="settingFollowRedirects"
title="Follow redirects"
description="Follow HTTP redirects automatically."
/>
<ModelSettingRowBoolean
model={workspace}
modelKey="settingSendCookies"
title="Automatically send cookies"
description="Attach matching cookies from the active cookie jar to outgoing requests."
/>
<ModelSettingRowBoolean
model={workspace}
modelKey="settingStoreCookies"
title="Automatically store cookies"
description="Save cookies from Set-Cookie response headers to the active cookie jar."
/>
</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 youre 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,522 +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,
name = String(modelKey),
...props
}: {
model: M;
modelKey: K;
name?: string;
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
return (
<SettingRowNumber
name={name}
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,
name = String(modelKey),
...props
}: {
model: M;
modelKey: K;
name?: string;
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
return (
<SettingRowText
name={name}
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,
name = String(modelKey),
...props
}: {
model: M;
modelKey: K;
name?: string;
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingSelectControl
name={name}
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,
name = String(modelKey),
...props
}: {
model: M;
modelKey: K;
name?: string;
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingRowSelect
name={name}
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>
);
}
-38
View File
@@ -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);
}
-22
View File
@@ -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,
);
-8
View File
@@ -1,8 +0,0 @@
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";
-35
View File
@@ -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 };
}
-9
View File
@@ -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
View File
@@ -1 +0,0 @@
export { YaakColor } from "@yaakapp-internal/theme";
-151
View File
@@ -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>()
-16
View File
@@ -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}",
],
};
-28
View File
@@ -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" }]
}
-64
View File
@@ -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;
}
-146
View File
@@ -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>
);
}
-219
View File
@@ -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>
);
}
-2
View File
@@ -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;
}
-15
View File
@@ -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]);
}
-18
View File
@@ -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,
});
}
-20
View File
@@ -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;
}
-28
View File
@@ -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>
-5
View File
@@ -1,5 +0,0 @@
export function fireAndForget(promise: Promise<unknown>) {
promise.catch((err: unknown) => {
console.error("Unhandled async error:", err);
});
}
-63
View File
@@ -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);
}
-17
View File
@@ -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);
}
-11
View File
@@ -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");
-30
View File
@@ -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();
}
-35
View File
@@ -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);
}
}
-92
View File
@@ -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%;
}
}
-44
View File
@@ -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>,
);
-37
View File
@@ -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"
}
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
plugins: [
require("@tailwindcss/nesting")(require("postcss-nesting")),
require("tailwindcss"),
require("autoprefixer"),
],
};
-7
View File
@@ -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}"],
};
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite-plus/client" />
-16
View File
@@ -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_"],
});
+4 -4
View File
@@ -6,17 +6,17 @@ use std::sync::Arc;
use tokio::sync::Mutex;
use yaak_crypto::manager::EncryptionManager;
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_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
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<'_> =
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)]
pub struct CliExecutionContext {
@@ -108,7 +108,7 @@ impl CliContext {
&self.data_dir
}
pub fn db(&self) -> ClientDb<'_> {
pub fn db(&self) -> DbContext<'_> {
self.query_manager.connect()
}
+12 -4
View File
@@ -14,7 +14,6 @@ use yaak::plugin_events::{
use yaak::render::{render_grpc_request, render_http_request};
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
@@ -473,7 +472,7 @@ async fn build_plugin_reply(
let names = cookie_jar
.cookies
.into_iter()
.map(|c| c.name)
.filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name))
.collect();
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
@@ -497,8 +496,10 @@ async fn build_plugin_reply(
}
};
let value =
get_cookie_value_from_jar(cookie_jar.cookies, &req.name, req.domain.as_deref());
let value = cookie_jar.cookies.into_iter().find_map(|c| {
let (name, value) = parse_cookie_name_value(&c.raw_cookie)?;
if name == req.name { Some(value) } else { None }
});
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value }))
}
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
}
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> {
let mut clipboard = Clipboard::new().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() {
Ok((mut stream, _)) => {
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
let mut request = Vec::new();
let mut request_buf = [0u8; 4096];
loop {
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 _ = stream.read(&mut request_buf);
let response = format!(
"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(&body_bytes);
let _ = stream.flush();
break;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
@@ -7,6 +7,34 @@ use tempfile::TempDir;
use yaak_models::models::HttpRequest;
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]
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
-21
View File
@@ -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" };
-8
View File
@@ -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
View File
@@ -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
View File
@@ -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"),
},
),
]
}
-31
View File
@@ -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)
}
}
-269
View File
@@ -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),
}
}
-119
View File
@@ -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}");
}
}
}
-5
View File
@@ -1,5 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri_app_client_lib::run();
}
-8
View File
@@ -1,8 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
target/
gen/*
**/permissions/autogenerated
**/permissions/schemas
-23
View File
@@ -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
View File
@@ -1 +0,0 @@
export {};
-3
View File
@@ -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"
]
}
-6
View File
@@ -1,6 +0,0 @@
{
"name": "@yaakapp-internal/tauri-proxy",
"version": "1.0.0",
"private": true,
"main": "bindings/index.ts"
}
-105
View File
@@ -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]
name = "yaak-app-client"
name = "yaak-app"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
@@ -7,7 +7,7 @@ publish = false
# Produce a library for mobile support
[lib]
name = "tauri_app_client_lib"
name = "tauri_app_lib"
crate-type = ["staticlib", "cdylib", "lib"]
[features]
@@ -17,7 +17,7 @@ updater = []
license = ["yaak-license"]
[build-dependencies]
tauri-build = { version = "2.6.1", features = [] }
tauri-build = { version = "2.5.3", features = [] }
[target.'cfg(target_os = "linux")'.dependencies]
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 }
log = { workspace = true }
md5 = "0.8.0"
notify = "8.0.0"
pretty_graphql = "0.2"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
@@ -50,15 +49,15 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
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-fs = "2.5.1"
tauri-plugin-log = { version = "2.8.0", features = ["colored"] }
tauri-plugin-opener = "2.5.4"
tauri-plugin-fs = "2.4.4"
tauri-plugin-log = { version = "2.7.1", features = ["colored"] }
tauri-plugin-opener = "2.5.2"
tauri-plugin-os = "2.3.2"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
tauri-plugin-updater = "2.10.1"
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-updater = "2.9.0"
tauri-plugin-window-state = "2.4.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
@@ -86,5 +85,4 @@ yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
yaak-window = { workspace = true }
yaak-ws = { workspace = true }
@@ -12,8 +12,6 @@ export type UpdateResponseAction = "install" | "skip";
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 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

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

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