mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-15 04:10:10 +02:00
Compare commits
13 Commits
dependabot
...
codex-revi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab785b18a4 | ||
|
|
dcfdf077e7 | ||
|
|
bde5a474cc | ||
|
|
21f1dad7a4 | ||
|
|
6dac1265f3 | ||
|
|
77ab293f87 | ||
|
|
471a099b9b | ||
|
|
b0b282535f | ||
|
|
19ed8c2f0d | ||
|
|
d7e67cf13c | ||
|
|
1b154ba550 | ||
|
|
947e3f2e97 | ||
|
|
8b1f5e807f |
@@ -1 +1,2 @@
|
||||
vp lint
|
||||
vp staged
|
||||
|
||||
1016
Cargo.lock
generated
1016
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -47,10 +47,10 @@ schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
tauri = "2.9.5"
|
||||
tauri-plugin = "2.5.2"
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-shell = "2.3.3"
|
||||
tauri = "2.11.1"
|
||||
tauri-plugin = "2.6.1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.48.0"
|
||||
ts-rs = "11.1.0"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Cookie } from "@yaakapp-internal/models";
|
||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import type { Cookie, CookieDomain, CookieJar } from "@yaakapp-internal/models";
|
||||
import { cookieJarsAtom, patchModelById } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { cookieDomain } from "../lib/model_util";
|
||||
import { showPromptForm } from "../lib/prompt-form";
|
||||
import { Banner, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
@@ -9,6 +10,109 @@ interface Props {
|
||||
cookieJarId: string | null;
|
||||
}
|
||||
|
||||
async function showAddCookieForm(cookieJarId: string): Promise<void> {
|
||||
const result = await showPromptForm({
|
||||
id: "add-cookie",
|
||||
title: "Add Cookie",
|
||||
size: "md",
|
||||
inputs: [
|
||||
{
|
||||
name: "cookie_pairs",
|
||||
label: "Cookie Attributes",
|
||||
type: "key_value",
|
||||
description:
|
||||
"Add key-value pairs for the cookie. These will be combined into the cookie string.",
|
||||
},
|
||||
{
|
||||
name: "domain_value",
|
||||
label: "Domain",
|
||||
type: "text",
|
||||
placeholder: "example.com",
|
||||
},
|
||||
{
|
||||
name: "hostOnly",
|
||||
label: "Host Only",
|
||||
type: "checkbox",
|
||||
defaultValue: "true",
|
||||
description:
|
||||
"If enabled, cookie is restricted to the exact host. Otherwise, it applies to the domain and its subdomains.",
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
label: "Path",
|
||||
type: "text",
|
||||
placeholder: "/",
|
||||
defaultValue: "/",
|
||||
},
|
||||
{
|
||||
name: "secure",
|
||||
label: "Secure",
|
||||
type: "checkbox",
|
||||
defaultValue: "true",
|
||||
description: "If enabled, cookie will only be sent over HTTPS connections.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
// Parse the form results
|
||||
const cookie_pairs_raw = result.cookie_pairs;
|
||||
const domain_value = (result.domain_value as string) ?? "";
|
||||
const path = (result.path as string) ?? "/";
|
||||
const hostOnly = (result.hostOnly as string) === "true";
|
||||
const secure = (result.secure as string) === "true";
|
||||
|
||||
// Convert key-value pairs to raw_cookie string format: key1=value1;key2=value2
|
||||
// Parse cookie_pairs - it comes as a JSON string from the key_value input
|
||||
let parsedPairs: Array<{ name: string; value: string }> = [];
|
||||
try {
|
||||
// Handle null, undefined, or string value
|
||||
const pairsStr =
|
||||
typeof cookie_pairs_raw === "string"
|
||||
? cookie_pairs_raw
|
||||
: cookie_pairs_raw != null
|
||||
? JSON.stringify(cookie_pairs_raw)
|
||||
: "[]";
|
||||
if (pairsStr && pairsStr !== "") {
|
||||
parsedPairs = JSON.parse(pairsStr);
|
||||
}
|
||||
} catch {
|
||||
parsedPairs = [];
|
||||
}
|
||||
|
||||
const validPairs = parsedPairs.filter((p) => p?.name?.trim());
|
||||
// Ensure at least one valid pair exists
|
||||
if (validPairs.length === 0) {
|
||||
console.log("No valid cookie pairs provided");
|
||||
return;
|
||||
}
|
||||
|
||||
const raw_cookie = validPairs.map((p) => `${p.name}=${p.value}`).join(";");
|
||||
|
||||
const domain: CookieDomain = hostOnly
|
||||
? { HostOnly: domain_value ?? "" }
|
||||
: { Suffix: domain_value ?? "" };
|
||||
|
||||
// Build the new cookie with explicit tuple type for path
|
||||
const newCookie: Cookie = {
|
||||
raw_cookie,
|
||||
domain,
|
||||
expires: "SessionEnd",
|
||||
path: [path, secure] as [string, boolean],
|
||||
};
|
||||
|
||||
try {
|
||||
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJarId, (prev) => ({
|
||||
...prev,
|
||||
cookies: [...prev.cookies, newCookie],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to add cookie:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
||||
@@ -17,12 +121,47 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
return <div>No cookie jar selected</div>;
|
||||
}
|
||||
|
||||
const onAddCookie = () => showAddCookieForm(cookieJar.id);
|
||||
|
||||
let tableBody;
|
||||
if (cookieJar.cookies.length === 0) {
|
||||
return (
|
||||
<Banner>
|
||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
|
||||
</Banner>
|
||||
tableBody = (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<Banner>
|
||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode>{" "}
|
||||
header
|
||||
</Banner>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
// );
|
||||
} else {
|
||||
tableBody = cookieJar?.cookies.map((c: Cookie) => (
|
||||
<tr key={JSON.stringify(c)}>
|
||||
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
||||
{cookieDomain(c)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
|
||||
{c.raw_cookie}
|
||||
</td>
|
||||
<td className="max-w-0 w-10">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Delete"
|
||||
className="ml-auto"
|
||||
onClick={async () =>
|
||||
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJar.id, (prev) => ({
|
||||
...prev,
|
||||
cookies: prev.cookies.filter((c2: Cookie) => c2 !== c),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -32,35 +171,19 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
<tr>
|
||||
<th className="py-2 text-left">Domain</th>
|
||||
<th className="py-2 text-left pl-4">Cookie</th>
|
||||
<th className="py-2 pl-4" />
|
||||
<th className="py-2 pl-4 w-10">
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Add Cookie"
|
||||
className="ml-auto"
|
||||
onClick={onAddCookie}
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{cookieJar?.cookies.map((c: Cookie) => (
|
||||
<tr key={JSON.stringify(c)}>
|
||||
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
||||
{cookieDomain(c)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
|
||||
{c.raw_cookie}
|
||||
</td>
|
||||
<td className="max-w-0 w-10">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Delete"
|
||||
className="ml-auto"
|
||||
onClick={() =>
|
||||
patchModel(cookieJar, {
|
||||
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tbody className="divide-y divide-surface-highlight">{tableBody}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { Compartment } from "@codemirror/state";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import { gitMutations } from "@yaakapp-internal/git";
|
||||
import type { GitStatus } from "@yaakapp-internal/git";
|
||||
import type {
|
||||
AnyModel,
|
||||
Folder,
|
||||
@@ -23,13 +25,18 @@ import {
|
||||
} from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily, selectAtom } from "jotai/utils";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { moveToWorkspace } from "../commands/moveToWorkspace";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import {
|
||||
activeWorkspaceAtom,
|
||||
activeWorkspaceIdAtom,
|
||||
activeWorkspaceMetaAtom,
|
||||
} from "../hooks/useActiveWorkspace";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
||||
import { getFolderActions } from "../hooks/useFolderActions";
|
||||
@@ -42,7 +49,13 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import {
|
||||
gitWorktreeStatusByModelIdAtom,
|
||||
gitWorktreeStatusFamily,
|
||||
} from "../lib/gitWorktreeStatus";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { isSidebarFocused } from "../lib/scopes";
|
||||
@@ -68,6 +81,9 @@ import type { InputHandle } from "./core/Input";
|
||||
import { Input } from "./core/Input";
|
||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
||||
import { GitDropdown } from "./git/GitDropdown";
|
||||
import { gitCallbacks } from "./git/callbacks";
|
||||
import { FileHistoryDialog } from "./git/FileHistoryDialog";
|
||||
import { sync } from "../init/sync";
|
||||
|
||||
const collapsedFamily = atomFamily((treeId: string) => {
|
||||
const key = ["sidebar_collapsed", treeId ?? "n/a"];
|
||||
@@ -375,6 +391,8 @@ function Sidebar({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir;
|
||||
const gitItems = getGitContextMenuItems({ items, syncDir });
|
||||
const onlyHttpRequests = items.every((i) => i.model === "http_request");
|
||||
const requestItems = items.filter(
|
||||
(i) =>
|
||||
@@ -458,8 +476,10 @@ function Sidebar({ className }: { className?: string }) {
|
||||
...initialItems,
|
||||
{
|
||||
type: "separator",
|
||||
hidden: initialItems.filter((v) => !v.hidden).length === 0,
|
||||
hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0,
|
||||
},
|
||||
...gitItems,
|
||||
{ type: "separator", hidden: gitItems.length === 0 },
|
||||
{
|
||||
label: "Rename",
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
@@ -661,6 +681,73 @@ function Sidebar({ className }: { className?: string }) {
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
function getGitContextMenuItems({
|
||||
items,
|
||||
syncDir,
|
||||
}: {
|
||||
items: SidebarModel[];
|
||||
syncDir: string | null | undefined;
|
||||
}): DropdownItem[] {
|
||||
if (syncDir == null) return [];
|
||||
|
||||
const gitStatusEntries = items.flatMap((item) => {
|
||||
const status = jotaiStore.get(gitWorktreeStatusFamily(item.id));
|
||||
return status == null || status.status === "current" ? [] : [status];
|
||||
});
|
||||
const historyItem = items.length === 1 ? items[0] : null;
|
||||
const historyPath =
|
||||
historyItem == null
|
||||
? null
|
||||
: (jotaiStore.get(gitWorktreeStatusFamily(historyItem.id))?.relaPath ??
|
||||
syncPathForModel(historyItem));
|
||||
|
||||
return [
|
||||
{
|
||||
label: "View History",
|
||||
leftSlot: <Icon icon="history" />,
|
||||
hidden: historyPath == null,
|
||||
onSelect: () => {
|
||||
if (historyPath == null) return;
|
||||
showDialog({
|
||||
id: "git-history",
|
||||
size: "lg",
|
||||
title: "File History",
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
render: () => <FileHistoryDialog dir={syncDir} relaPath={historyPath} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Restore Changes",
|
||||
leftSlot: <Icon icon="rotate_ccw" />,
|
||||
hidden: gitStatusEntries.length === 0,
|
||||
async onSelect() {
|
||||
const confirmed = await showConfirm({
|
||||
id: "git-restore-sidebar-items",
|
||||
title: "Restore Changes",
|
||||
description:
|
||||
gitStatusEntries.length === 1
|
||||
? "This will discard uncommitted changes for the selected item."
|
||||
: `This will discard uncommitted changes for ${gitStatusEntries.length} selected items.`,
|
||||
confirmText: "Restore",
|
||||
color: "danger",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
await gitMutations(syncDir, gitCallbacks(syncDir)).restore.mutateAsync({
|
||||
relaPaths: gitStatusEntries.map((entry) => entry.relaPath),
|
||||
});
|
||||
await sync({ force: true });
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function syncPathForModel(item: SidebarModel) {
|
||||
return `yaak.${item.id}.yaml`;
|
||||
}
|
||||
|
||||
const activeIdAtom = atom<string | null>((get) => {
|
||||
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
||||
});
|
||||
@@ -790,6 +877,64 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
||||
return [root, fields] as const;
|
||||
});
|
||||
|
||||
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
||||
const allModels = get(memoAllPotentialChildrenAtom);
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
const gitStatusByModelId = get(gitWorktreeStatusByModelIdAtom);
|
||||
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
|
||||
const statusByModelId: Record<string, GitStatus> = {};
|
||||
|
||||
for (const item of allModels) {
|
||||
if ("folderId" in item && item.folderId == null) {
|
||||
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
|
||||
childrenMap[item.workspaceId]?.push(item);
|
||||
} else if ("folderId" in item && item.folderId != null) {
|
||||
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
|
||||
childrenMap[item.folderId]?.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (item: SidebarModel): GitStatus | null => {
|
||||
const statuses: GitStatus[] = [];
|
||||
const directStatus = gitStatusByModelId[item.id]?.status;
|
||||
if (directStatus != null && directStatus !== "current") {
|
||||
statuses.push(directStatus);
|
||||
}
|
||||
|
||||
for (const child of childrenMap[item.id] ?? []) {
|
||||
const childStatus = visit(child);
|
||||
if (childStatus != null) statuses.push(childStatus);
|
||||
}
|
||||
|
||||
const status = summarizeGitStatuses(statuses);
|
||||
if (status != null) {
|
||||
statusByModelId[item.id] = status;
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
if (activeWorkspace != null) {
|
||||
visit(activeWorkspace);
|
||||
}
|
||||
|
||||
return statusByModelId;
|
||||
});
|
||||
|
||||
const sidebarGitStatusFamily = atomFamily(
|
||||
(modelId: string) =>
|
||||
selectAtom(sidebarGitStatusByModelIdAtom, (statusByModelId) => statusByModelId[modelId] ?? null),
|
||||
Object.is,
|
||||
);
|
||||
|
||||
function summarizeGitStatuses(statuses: GitStatus[]): GitStatus | null {
|
||||
if (statuses.length === 0) return null;
|
||||
const firstStatus = statuses[0];
|
||||
if (firstStatus != null && statuses.every((status) => status === firstStatus)) {
|
||||
return firstStatus;
|
||||
}
|
||||
return "modified";
|
||||
}
|
||||
|
||||
function getItemKey(item: SidebarModel) {
|
||||
const responses = jotaiStore.get(httpResponsesAtom);
|
||||
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
||||
@@ -836,6 +981,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
||||
treeId: string;
|
||||
item: SidebarModel;
|
||||
}) {
|
||||
const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
|
||||
const response = useAtomValue(
|
||||
useMemo(
|
||||
() =>
|
||||
@@ -854,7 +1000,16 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
||||
<div className="truncate">{resolvedModelName(item)}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"truncate",
|
||||
gitStatus === "modified" && "text-info",
|
||||
gitStatus === "untracked" && "text-success",
|
||||
gitStatus === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{resolvedModelName(item)}
|
||||
</div>
|
||||
{response != null && (
|
||||
<div className="ml-auto">
|
||||
{response.state !== "closed" ? (
|
||||
|
||||
@@ -128,7 +128,7 @@ export function Workspace() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid w-full h-full grid-rows-[auto_1fr]">
|
||||
<div className="grid w-full h-full grid-rows-[auto_minmax(0,1fr)]">
|
||||
{header}
|
||||
<SidebarLayout
|
||||
width={width ?? 250}
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
|
||||
export function HttpResponseDurationTag({ response }: Props) {
|
||||
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
|
||||
const timeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const timeout = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
|
||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
|
||||
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const showTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const handleOpenImmediate = () => {
|
||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
||||
|
||||
131
apps/yaak-client/components/git/FileHistoryDialog.tsx
Normal file
131
apps/yaak-client/components/git/FileHistoryDialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import type {
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { modelToYaml } from "../../lib/diffYaml";
|
||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { showErrorToast } from "../../lib/toast";
|
||||
import { sync } from "../../init/sync";
|
||||
import { Button } from "../core/Button";
|
||||
import type { CheckboxProps } from "../core/Checkbox";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
@@ -21,7 +23,7 @@ import { DiffViewer } from "../core/Editor/DiffViewer";
|
||||
import { Input } from "../core/Input";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { EmptyStateText } from "../EmptyStateText";
|
||||
import { gitCallbacks } from "./callbacks";
|
||||
import { useGitCallbacks } from "./callbacks";
|
||||
import { handlePushResult } from "./git-util";
|
||||
|
||||
interface Props {
|
||||
@@ -38,9 +40,10 @@ interface CommitTreeNode {
|
||||
}
|
||||
|
||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
const [{ status }, { commit, commitAndPush, add, unstage }] = useGit(
|
||||
const callbacks = useGitCallbacks(syncDir);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
||||
syncDir,
|
||||
gitCallbacks(syncDir),
|
||||
callbacks,
|
||||
);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string | null>(null);
|
||||
@@ -165,6 +168,24 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
[selectedEntry],
|
||||
);
|
||||
|
||||
const handleDiscardChanges = useCallback(
|
||||
async (entry: GitStatusEntry) => {
|
||||
const confirmed = await showConfirm({
|
||||
id: "git-restore-commit-entry",
|
||||
title: "Discard Changes",
|
||||
description: "Do you really want to discard uncommitted changes for the selected item?",
|
||||
confirmText: "Discard",
|
||||
color: "danger",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
await restore.mutateAsync({ relaPaths: [entry.relaPath] });
|
||||
await sync({ force: true });
|
||||
setSelectedEntry(null);
|
||||
},
|
||||
[restore],
|
||||
);
|
||||
|
||||
if (tree == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -259,7 +280,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
secondSlot={({ style }) => (
|
||||
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
|
||||
{selectedEntry ? (
|
||||
<DiffPanel entry={selectedEntry} />
|
||||
<DiffPanel entry={selectedEntry} onDiscardChanges={handleDiscardChanges} />
|
||||
) : (
|
||||
<EmptyStateText>Select a change to view diff</EmptyStateText>
|
||||
)}
|
||||
@@ -466,16 +487,35 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
|
||||
return node.children.some((c) => isNodeRelevant(c));
|
||||
}
|
||||
|
||||
function DiffPanel({ entry }: { entry: GitStatusEntry }) {
|
||||
function DiffPanel({
|
||||
entry,
|
||||
onDiscardChanges,
|
||||
}: {
|
||||
entry: GitStatusEntry;
|
||||
onDiscardChanges: (entry: GitStatusEntry) => void | Promise<void>;
|
||||
}) {
|
||||
const prevYaml = modelToYaml(entry.prev);
|
||||
const nextYaml = modelToYaml(entry.next);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="text-sm text-text-subtle mb-2 px-1">
|
||||
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
|
||||
<div className="text-text-subtle mb-2 px-1 grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0 truncate">
|
||||
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
|
||||
</div>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="warning"
|
||||
size="2xs"
|
||||
variant="border"
|
||||
onClick={() => onDiscardChanges(entry)}
|
||||
>Discard Changes</Button>
|
||||
</div>
|
||||
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
||||
<DiffViewer
|
||||
original={prevYaml ?? ""}
|
||||
modified={nextYaml ?? ""}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useGit } from "@yaakapp-internal/git";
|
||||
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 } from "react";
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
@@ -12,17 +12,20 @@ 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 { gitCallbacks } from "./callbacks";
|
||||
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;
|
||||
@@ -36,469 +39,493 @@ export function GitDropdown() {
|
||||
|
||||
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
|
||||
const [refreshKey, regenerateKey] = useRandomKey();
|
||||
const [
|
||||
{ status, log },
|
||||
{
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
deleteRemoteBranch,
|
||||
renameBranch,
|
||||
mergeBranch,
|
||||
push,
|
||||
pull,
|
||||
checkout,
|
||||
resetChanges,
|
||||
init,
|
||||
},
|
||||
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
|
||||
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 = status.data?.localBranches ?? [];
|
||||
const remoteBranches = status.data?.remoteBranches ?? [];
|
||||
const remoteOnlyBranches = remoteBranches.filter(
|
||||
(b) => !localBranches.includes(b.replace(/^origin\//, "")),
|
||||
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 = status.error?.includes("not found");
|
||||
const noRepo = branchInfo.error?.includes("not found");
|
||||
if (noRepo) {
|
||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
|
||||
}
|
||||
|
||||
// Still loading
|
||||
if (status.data == null) {
|
||||
if (branchInfo.data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentBranch = status.data.headRefShorthand;
|
||||
const hasChanges = status.data.entries.some((e) => e.status !== "current");
|
||||
const _hasRemotes = (status.data.origins ?? []).length > 0;
|
||||
const { ahead, behind } = status.data;
|
||||
|
||||
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 });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
label: "View History...",
|
||||
hidden: (log.data ?? []).length === 0,
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: async () => {
|
||||
showDialog({
|
||||
id: "git-history",
|
||||
size: "md",
|
||||
title: "Commit History",
|
||||
noPadding: true,
|
||||
render: () => <HistoryDialog log={log.data ?? []} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
||||
<GitMenuButton>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { gitCallbacks } from "./callbacks";
|
||||
import { useGitCallbacks } from "./callbacks";
|
||||
import { addGitRemote } from "./showAddRemoteDialog";
|
||||
|
||||
interface Props {
|
||||
@@ -19,7 +19,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function GitRemotesDialog({ dir }: Props) {
|
||||
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
|
||||
const callbacks = useGitCallbacks(dir);
|
||||
const [{ remotes }, { rmRemote }] = useGit(dir, callbacks);
|
||||
|
||||
return (
|
||||
<Table scrollable>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitCommit } from "@yaakapp-internal/git";
|
||||
import { useGitLog } from "@yaakapp-internal/git";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
Table,
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
TruncatedWideTableCell,
|
||||
} from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
log: GitCommit[];
|
||||
}
|
||||
export function HistoryDialog({ dir }: { dir: string }) {
|
||||
const log = useGitLog(dir);
|
||||
|
||||
export function HistoryDialog({ log }: Props) {
|
||||
return (
|
||||
<div className="pl-5 pr-1 pb-1">
|
||||
<Table scrollable className="px-1">
|
||||
@@ -26,8 +24,8 @@ export function HistoryDialog({ log }: Props) {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{log.map((l) => (
|
||||
<TableRow key={(l.author.name ?? "") + (l.message ?? "n/a") + l.when}>
|
||||
{(log.data ?? []).map((l) => (
|
||||
<TableRow key={l.oid}>
|
||||
<TruncatedWideTableCell>
|
||||
{l.message || <em className="text-text-subtle">No message</em>}
|
||||
</TruncatedWideTableCell>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GitCallbacks } from "@yaakapp-internal/git";
|
||||
import { useMemo } from "react";
|
||||
import { sync } from "../../init/sync";
|
||||
import { promptCredentials } from "./credentials";
|
||||
import { promptDivergedStrategy } from "./diverged";
|
||||
@@ -24,3 +25,7 @@ export function gitCallbacks(dir: string): GitCallbacks {
|
||||
forceSync: () => sync({ force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGitCallbacks(dir: string): GitCallbacks {
|
||||
return useMemo(() => gitCallbacks(dir), [dir]);
|
||||
}
|
||||
|
||||
38
apps/yaak-client/init/git.ts
Normal file
38
apps/yaak-client/init/git.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { watchGitWorktreeStatus, type GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
||||
import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
||||
import { gitWorktreeStatusAtom, gitWorktreeStatusByModelIdAtom } from "../lib/gitWorktreeStatus";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export function initGit() {
|
||||
let watchedDir: string | null = null;
|
||||
let unwatch: null | ReturnType<typeof watchGitWorktreeStatus> = null;
|
||||
|
||||
const watchActiveWorkspace = () => {
|
||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir ?? null;
|
||||
if (syncDir === watchedDir) return;
|
||||
|
||||
void unwatch?.();
|
||||
unwatch = null;
|
||||
watchedDir = syncDir;
|
||||
jotaiStore.set(gitWorktreeStatusAtom, null);
|
||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, {});
|
||||
|
||||
if (syncDir == null) return;
|
||||
|
||||
unwatch = watchGitWorktreeStatus(syncDir, (status) => {
|
||||
if (syncDir !== watchedDir) return;
|
||||
|
||||
jotaiStore.set(gitWorktreeStatusAtom, status);
|
||||
|
||||
const statusByModelId: Record<string, GitWorktreeStatusEntry> = {};
|
||||
for (const entry of status.entries) {
|
||||
if (entry.modelId == null) continue;
|
||||
statusByModelId[entry.modelId] = entry;
|
||||
}
|
||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, statusByModelId);
|
||||
});
|
||||
};
|
||||
|
||||
watchActiveWorkspace();
|
||||
jotaiStore.sub(activeWorkspaceMetaAtom, watchActiveWorkspace);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import { debounce, eagerDebounceAsync } from "@yaakapp-internal/lib";
|
||||
import type { AnyModel, ModelPayload } from "@yaakapp-internal/models";
|
||||
import { watchWorkspaceFiles } from "@yaakapp-internal/sync";
|
||||
import { syncWorkspace } from "../commands/commands";
|
||||
@@ -25,9 +25,8 @@ export async function sync({ force }: { force?: boolean } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
const debouncedSync = debounce(async () => {
|
||||
await sync();
|
||||
}, 1000);
|
||||
const syncAfterFileChange = debounce(sync, 1000);
|
||||
const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
|
||||
|
||||
/**
|
||||
* Subscribe to model change events. Since we check the workspace ID on sync, we can
|
||||
@@ -35,7 +34,7 @@ const debouncedSync = debounce(async () => {
|
||||
*/
|
||||
function initModelListeners() {
|
||||
listenToTauriEvent<ModelPayload>("model_write", (p) => {
|
||||
if (isModelRelevant(p.payload.model)) debouncedSync();
|
||||
if (isModelRelevant(p.payload.model)) syncAfterModelWrite();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,11 +49,11 @@ function initFileChangeListeners() {
|
||||
await unsub?.(); // Unsub to previous
|
||||
const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);
|
||||
if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;
|
||||
debouncedSync(); // Perform an initial sync when switching workspace
|
||||
syncAfterFileChange(); // Perform an initial sync when switching workspace
|
||||
unsub = watchWorkspaceFiles(
|
||||
workspaceMeta.workspaceId,
|
||||
workspaceMeta.settingSyncDir,
|
||||
debouncedSync,
|
||||
syncAfterFileChange,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { SyncModel } from "@yaakapp-internal/git";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Convert a SyncModel to a clean YAML string for diffing.
|
||||
* Removes noisy fields like updatedAt that change on every edit.
|
||||
* Convert a SyncModel to a YAML string for diffing.
|
||||
*/
|
||||
export function modelToYaml(model: SyncModel | null): string {
|
||||
if (!model) return "";
|
||||
|
||||
22
apps/yaak-client/lib/gitWorktreeStatus.ts
Normal file
22
apps/yaak-client/lib/gitWorktreeStatus.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { changeModelStoreWorkspace, initModelStore } from "@yaakapp-internal/mod
|
||||
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { initGit } from "./init/git";
|
||||
import { initSync } from "./init/sync";
|
||||
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
||||
import { jotaiStore } from "./lib/jotai";
|
||||
@@ -31,6 +32,7 @@ window.addEventListener("keydown", (e) => {
|
||||
});
|
||||
|
||||
// Initialize a bunch of watchers
|
||||
initGit();
|
||||
initSync();
|
||||
initModelStore(jotaiStore);
|
||||
initGlobalListeners();
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-router": "^1.133.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.4",
|
||||
"@tauri-apps/plugin-log": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.3",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.5.1",
|
||||
"cm6-graphql": "^0.2.1",
|
||||
@@ -52,6 +52,7 @@
|
||||
"hexy": "^0.3.5",
|
||||
"history": "^5.3.0",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
@@ -97,7 +98,7 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"decompress": "^4.2.1",
|
||||
"internal-ip": "^8.0.0",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-nesting": "^13.0.2",
|
||||
"rollup": "^4.60.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
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";
|
||||
@@ -42,12 +42,15 @@ export default defineConfig(async () => {
|
||||
sourcemap: true,
|
||||
outDir: "../../dist/apps/yaak-client",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ 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/utils";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { useCallback } from "react";
|
||||
import { httpExchangesAtom } from "../lib/store";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@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",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@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"
|
||||
|
||||
@@ -25,11 +25,7 @@ pub struct ActionMetadata {
|
||||
}
|
||||
|
||||
fn default_hotkey(mac: &str, other: &str) -> Option<String> {
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(mac.into())
|
||||
} else {
|
||||
Some(other.into())
|
||||
}
|
||||
if cfg!(target_os = "macos") { Some(mac.into()) } else { Some(other.into()) }
|
||||
}
|
||||
|
||||
/// All global actions with their metadata, used by `list_actions` RPC.
|
||||
|
||||
@@ -14,10 +14,8 @@ pub struct ProxyQueryManager {
|
||||
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");
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -2,18 +2,18 @@ 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 log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use yaak_database::{ModelChangeEvent, UpdateSource};
|
||||
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
|
||||
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
|
||||
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
||||
use crate::db::ProxyQueryManager;
|
||||
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
|
||||
|
||||
// -- Context --
|
||||
|
||||
@@ -25,11 +25,7 @@ pub struct ProxyCtx {
|
||||
|
||||
impl ProxyCtx {
|
||||
pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self {
|
||||
Self {
|
||||
handle: Mutex::new(None),
|
||||
db: ProxyQueryManager::new(db_path),
|
||||
events,
|
||||
}
|
||||
Self { handle: Mutex::new(None), db: ProxyQueryManager::new(db_path), events }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,17 +84,15 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
|
||||
match invocation {
|
||||
ActionInvocation::Global { action } => match action {
|
||||
GlobalAction::ProxyStart => {
|
||||
let mut handle = ctx
|
||||
.handle
|
||||
.lock()
|
||||
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
||||
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 })?;
|
||||
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();
|
||||
@@ -107,49 +101,43 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
|
||||
}
|
||||
|
||||
*handle = Some(proxy_handle);
|
||||
ctx.events.emit("proxy_state_changed", &ProxyStatePayload {
|
||||
state: ProxyState::Running,
|
||||
});
|
||||
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() })?;
|
||||
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,
|
||||
});
|
||||
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
|
||||
};
|
||||
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_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>()
|
||||
http_exchanges: db
|
||||
.find_all::<HttpExchange>()
|
||||
.map_err(|e| RpcError { message: e.to_string() })?,
|
||||
})
|
||||
})
|
||||
@@ -157,28 +145,35 @@ fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResp
|
||||
|
||||
// -- Event loop --
|
||||
|
||||
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager, events: RpcEventEmitter) {
|
||||
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 {
|
||||
in_flight.insert(
|
||||
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,
|
||||
});
|
||||
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) {
|
||||
@@ -230,28 +225,30 @@ fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedReq
|
||||
let entry = HttpExchange {
|
||||
url: r.url.clone(),
|
||||
method: r.method.clone(),
|
||||
req_headers: r.request_headers.iter()
|
||||
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()
|
||||
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}"),
|
||||
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}"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ 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};
|
||||
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")]
|
||||
|
||||
@@ -17,7 +17,7 @@ updater = []
|
||||
license = ["yaak-license"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
tauri-build = { version = "2.6.1", features = [] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
@@ -49,15 +50,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.5"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-log = { version = "2.7.1", features = ["colored"] }
|
||||
tauri-plugin-opener = "2.5.2"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-log = { version = "2.8.0", features = ["colored"] }
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-shell = { workspace = true }
|
||||
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
|
||||
tauri-plugin-updater = "2.9.0"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
|
||||
2
crates-tauri/yaak-app-client/bindings/index.ts
generated
2
crates-tauri/yaak-app-client/bindings/index.ts
generated
@@ -12,6 +12,8 @@ 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, };
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
//! This module provides the Tauri commands for git functionality.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::git_watcher::{GitWatchResult, watch_git_worktree_status};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::command;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Runtime, command};
|
||||
use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
||||
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
||||
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
|
||||
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
|
||||
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
|
||||
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult, git_add, git_add_credential,
|
||||
git_add_remote, git_branch_info, git_checkout_branch, git_clone, git_commit, git_create_branch,
|
||||
git_delete_branch, git_delete_remote_branch, git_fetch_all, git_file_diff_for_commit, git_init,
|
||||
git_log, git_log_for_file, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge,
|
||||
git_push, git_remotes, git_rename_branch, git_reset_changes, git_restore,
|
||||
git_restore_file_from_commit, git_rm_remote, git_status, git_unstage, git_worktree_status,
|
||||
};
|
||||
|
||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||
@@ -54,11 +58,44 @@ pub async fn cmd_git_status(dir: &Path) -> Result<GitStatusSummary> {
|
||||
Ok(git_status(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_branch_info(dir: &Path) -> Result<GitBranchInfo> {
|
||||
Ok(git_branch_info(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_worktree_status(dir: &Path) -> Result<GitWorktreeStatus> {
|
||||
Ok(git_worktree_status(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_watch_worktree_status<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
dir: &Path,
|
||||
channel: Channel<GitWorktreeStatus>,
|
||||
) -> Result<GitWatchResult> {
|
||||
watch_git_worktree_status(app_handle, dir, channel).await
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
||||
Ok(git_log(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_log_for_file(dir: &Path, rela_path: PathBuf) -> Result<Vec<GitCommit>> {
|
||||
Ok(git_log_for_file(dir, &rela_path)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_file_diff_for_commit(
|
||||
dir: &Path,
|
||||
commit_oid: &str,
|
||||
rela_path: PathBuf,
|
||||
) -> Result<GitFileDiff> {
|
||||
Ok(git_file_diff_for_commit(dir, commit_oid, &rela_path)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
||||
Ok(git_init(dir)?)
|
||||
@@ -124,6 +161,23 @@ pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
|
||||
Ok(git_reset_changes(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_restore_files(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||
for path in rela_paths {
|
||||
git_restore(dir, &path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_restore_file_from_commit(
|
||||
dir: &Path,
|
||||
commit_oid: &str,
|
||||
rela_path: PathBuf,
|
||||
) -> Result<()> {
|
||||
Ok(git_restore_file_from_commit(dir, commit_oid, &rela_path)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add_credential(
|
||||
remote_url: &str,
|
||||
|
||||
172
crates-tauri/yaak-app-client/src/git_watcher.rs
Normal file
172
crates-tauri/yaak-app-client/src/git_watcher.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ mod commands;
|
||||
mod encoding;
|
||||
mod error;
|
||||
mod git_ext;
|
||||
mod git_watcher;
|
||||
mod grpc;
|
||||
mod history;
|
||||
mod http_request;
|
||||
@@ -121,9 +122,7 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
|
||||
}
|
||||
|
||||
// Commands for development
|
||||
"dev.reset_size" => webview_window
|
||||
.set_size(LogicalSize::new(1100.0, 600.0))
|
||||
.unwrap(),
|
||||
"dev.reset_size" => webview_window.set_size(LogicalSize::new(1100.0, 600.0)).unwrap(),
|
||||
"dev.reset_size_16x9" => {
|
||||
let width = webview_window.outer_size().unwrap().width;
|
||||
let height = width * 9 / 16;
|
||||
@@ -1506,7 +1505,6 @@ async fn cmd_reload_plugins<R: Runtime>(
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_plugin_info<R: Runtime>(
|
||||
id: &str,
|
||||
@@ -1579,7 +1577,14 @@ async fn cmd_new_child_window(
|
||||
inner_size: (f64, f64),
|
||||
) -> YaakResult<()> {
|
||||
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
|
||||
let win = yaak_window::window::create_child_window(&parent_window, url, label, title, inner_size, use_native_titlebar)?;
|
||||
let win = yaak_window::window::create_child_window(
|
||||
&parent_window,
|
||||
url,
|
||||
label,
|
||||
title,
|
||||
inner_size,
|
||||
use_native_titlebar,
|
||||
)?;
|
||||
setup_window_menu(&win)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1831,8 +1836,13 @@ pub fn run() {
|
||||
git_ext::cmd_git_delete_remote_branch,
|
||||
git_ext::cmd_git_merge_branch,
|
||||
git_ext::cmd_git_rename_branch,
|
||||
git_ext::cmd_git_branch_info,
|
||||
git_ext::cmd_git_status,
|
||||
git_ext::cmd_git_worktree_status,
|
||||
git_ext::cmd_git_watch_worktree_status,
|
||||
git_ext::cmd_git_log,
|
||||
git_ext::cmd_git_log_for_file,
|
||||
git_ext::cmd_git_file_diff_for_commit,
|
||||
git_ext::cmd_git_initialize,
|
||||
git_ext::cmd_git_clone,
|
||||
git_ext::cmd_git_commit,
|
||||
@@ -1844,6 +1854,8 @@ pub fn run() {
|
||||
git_ext::cmd_git_add,
|
||||
git_ext::cmd_git_unstage,
|
||||
git_ext::cmd_git_reset_changes,
|
||||
git_ext::cmd_git_restore_files,
|
||||
git_ext::cmd_git_restore_file_from_commit,
|
||||
git_ext::cmd_git_add_credential,
|
||||
git_ext::cmd_git_remotes,
|
||||
git_ext::cmd_git_add_remote,
|
||||
@@ -1870,7 +1882,11 @@ pub fn run() {
|
||||
match event {
|
||||
RunEvent::Ready => {
|
||||
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
|
||||
if let Ok(win) = yaak_window::window::create_main_window(app_handle, "/", use_native_titlebar) {
|
||||
if let Ok(win) = yaak_window::window::create_main_window(
|
||||
app_handle,
|
||||
"/",
|
||||
use_native_titlebar,
|
||||
) {
|
||||
let _ = setup_window_menu(&win);
|
||||
}
|
||||
let h = app_handle.clone();
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::http_request::send_http_request_with_context;
|
||||
use crate::models_ext::BlobManagerExt;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use crate::render::{render_grpc_request, render_http_request, render_json_value};
|
||||
use yaak_window::window::{CreateWindowConfig, create_window};
|
||||
use crate::{
|
||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||
workspace_from_window,
|
||||
@@ -36,6 +35,7 @@ use yaak_plugins::plugin_handle::PluginHandle;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_window::window::{CreateWindowConfig, create_window};
|
||||
|
||||
pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
|
||||
@@ -234,7 +234,7 @@ async fn start_integrated_update<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<UpdateResponseAction> {
|
||||
let download_path = ensure_download_path(window, update)?;
|
||||
let download_path = ensure_download_dir(window)?.join(download_file_name(update));
|
||||
debug!("Download path: {}", download_path.display());
|
||||
let downloaded = download_path.exists();
|
||||
let ack_wait = Duration::from_secs(3);
|
||||
@@ -345,7 +345,7 @@ pub async fn download_update_idempotent<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
let dl_path = ensure_download_path(window, update)?;
|
||||
let dl_path = ensure_download_dir(window)?.join(download_file_name(update));
|
||||
|
||||
if dl_path.exists() {
|
||||
info!("{} already downloaded to {}", update.version, dl_path.display());
|
||||
@@ -385,21 +385,36 @@ pub async fn install_update_maybe_download<R: Runtime>(
|
||||
let dl_path = download_update_idempotent(window, update).await?;
|
||||
let update_bytes = std::fs::read(&dl_path)?;
|
||||
update.install(update_bytes.as_slice())?;
|
||||
delete_download_dir(window);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_download_path<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
// Ensure dir exists
|
||||
let base_dir = window.path().app_cache_dir()?.join("updates");
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
|
||||
// Generate name based on signature
|
||||
let sig_digest = md5::compute(&update.signature);
|
||||
let name = format!("yaak-{}-{:x}", update.version, sig_digest);
|
||||
let dl_path = base_dir.join(name);
|
||||
|
||||
Ok(dl_path)
|
||||
pub fn download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
Ok(window.path().app_cache_dir()?.join("updates"))
|
||||
}
|
||||
|
||||
pub fn ensure_download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
let base_dir = download_dir(window)?;
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(base_dir)
|
||||
}
|
||||
|
||||
pub fn download_file_name(update: &Update) -> String {
|
||||
let sig_digest = md5::compute(&update.signature);
|
||||
format!("yaak-{}-{:x}", update.version, sig_digest)
|
||||
}
|
||||
|
||||
pub fn delete_download_dir<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
let base_dir = match download_dir(window) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
warn!("Failed to locate update downloads dir: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match std::fs::remove_dir_all(&base_dir) {
|
||||
Ok(()) => info!("Removed update downloads dir {}", base_dir.display()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => warn!("Failed to remove update downloads dir {}: {}", base_dir.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ name = "tauri_app_proxy_lib"
|
||||
crate-type = ["staticlib", "cdylib", "lib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
tauri-build = { version = "2.6.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use log::{error, info, warn};
|
||||
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
|
||||
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;
|
||||
|
||||
@@ -109,19 +109,16 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64,
|
||||
// we've modified it. This avoids the height growing on repeated calls.
|
||||
use std::sync::OnceLock;
|
||||
static DEFAULT_TITLEBAR_HEIGHT: OnceLock<f64> = OnceLock::new();
|
||||
let default_height =
|
||||
*DEFAULT_TITLEBAR_HEIGHT.get_or_init(|| NSView::frame(title_bar_container_view).size.height);
|
||||
let default_height = *DEFAULT_TITLEBAR_HEIGHT
|
||||
.get_or_init(|| NSView::frame(title_bar_container_view).size.height);
|
||||
|
||||
// On pre-Tahoe, button_height + y is larger than the default title bar
|
||||
// height, so the resize works as before. On Tahoe (26+), the default is
|
||||
// already 32px and button_height + y = 32, so nothing changes. In that
|
||||
// case, add TITLEBAR_EXTRA_HEIGHT extra pixels to push the buttons down.
|
||||
let desired = button_height + y;
|
||||
let title_bar_frame_height = if desired > default_height {
|
||||
desired
|
||||
} else {
|
||||
default_height + TITLEBAR_EXTRA_HEIGHT
|
||||
};
|
||||
let title_bar_frame_height =
|
||||
if desired > default_height { desired } else { default_height + TITLEBAR_EXTRA_HEIGHT };
|
||||
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
|
||||
@@ -65,8 +65,7 @@ impl<'a> DbContext<'a> {
|
||||
.cond_where(Expr::col(col).eq(value))
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
|
||||
stmt.query_row(&*params.as_params(), M::from_row)
|
||||
.ok()
|
||||
stmt.query_row(&*params.as_params(), M::from_row).ok()
|
||||
}
|
||||
|
||||
pub fn find_all<M>(&self) -> Result<Vec<M>>
|
||||
@@ -126,9 +125,8 @@ impl<'a> DbContext<'a> {
|
||||
let other_values = model.clone().insert_values(source)?;
|
||||
|
||||
let mut column_vec = vec![id_iden.clone()];
|
||||
let mut value_vec = vec![
|
||||
if id_val.is_empty() { M::generate_id().into() } else { id_val.into() },
|
||||
];
|
||||
let mut value_vec =
|
||||
vec![if id_val.is_empty() { M::generate_id().into() } else { id_val.into() }];
|
||||
|
||||
for (col, val) in other_values {
|
||||
value_vec.push(val.into());
|
||||
|
||||
@@ -55,8 +55,7 @@ pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Re
|
||||
continue;
|
||||
}
|
||||
|
||||
let sql =
|
||||
entry.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
|
||||
let sql = entry.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
|
||||
|
||||
info!("Applying migration: {}", filename);
|
||||
let conn = pool.get()?;
|
||||
|
||||
@@ -10,10 +10,10 @@ pub fn generate_id() -> String {
|
||||
|
||||
pub fn generate_id_of_length(n: usize) -> String {
|
||||
let alphabet: [char; 57] = [
|
||||
'2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
|
||||
'j', 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A',
|
||||
'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T',
|
||||
'U', 'V', 'W', 'X', 'Y', 'Z',
|
||||
'2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
|
||||
'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C',
|
||||
'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
|
||||
'X', 'Y', 'Z',
|
||||
];
|
||||
|
||||
nanoid!(n, &alphabet)
|
||||
|
||||
@@ -3,7 +3,8 @@ use std::collections::HashMap;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Type-erased handler function: takes context + JSON payload, returns JSON or error.
|
||||
type HandlerFn<Ctx> = Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>;
|
||||
type HandlerFn<Ctx> =
|
||||
Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcError {
|
||||
@@ -57,9 +58,7 @@ pub struct RpcRouter<Ctx> {
|
||||
|
||||
impl<Ctx> RpcRouter<Ctx> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
Self { handlers: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Register a handler for a command name.
|
||||
@@ -77,23 +76,15 @@ impl<Ctx> RpcRouter<Ctx> {
|
||||
) -> Result<serde_json::Value, RpcError> {
|
||||
match self.handlers.get(cmd) {
|
||||
Some(handler) => handler(ctx, payload),
|
||||
None => Err(RpcError {
|
||||
message: format!("unknown command: {cmd}"),
|
||||
}),
|
||||
None => Err(RpcError { message: format!("unknown command: {cmd}") }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a full `RpcRequest`, returning an `RpcResponse`.
|
||||
pub fn handle(&self, req: RpcRequest, ctx: &Ctx) -> RpcResponse {
|
||||
match self.dispatch(&req.cmd, req.payload, ctx) {
|
||||
Ok(payload) => RpcResponse::Success {
|
||||
id: req.id,
|
||||
payload,
|
||||
},
|
||||
Err(e) => RpcResponse::Error {
|
||||
id: req.id,
|
||||
error: e.message,
|
||||
},
|
||||
Ok(payload) => RpcResponse::Success { id: req.id, payload },
|
||||
Err(e) => RpcResponse::Error { id: req.id, error: e.message },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
crates/yaak-git/bindings/gen_git.ts
generated
10
crates/yaak-git/bindings/gen_git.ts
generated
@@ -7,7 +7,11 @@ export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "t
|
||||
|
||||
export type GitAuthor = { name: string | null, email: string | null, };
|
||||
|
||||
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
||||
export type GitBranchInfo = { path: string, headRef: string | null, headRefShorthand: string | null, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
||||
|
||||
export type GitCommit = { oid: string, author: GitAuthor, when: string, message: string | null, };
|
||||
|
||||
export type GitFileDiff = { original: string, modified: string, };
|
||||
|
||||
export type GitRemote = { name: string, url: string | null, };
|
||||
|
||||
@@ -17,6 +21,10 @@ export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: bool
|
||||
|
||||
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
||||
|
||||
export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
|
||||
|
||||
export type GitWorktreeStatusEntry = { relaPath: string, modelId: string | null, status: GitStatus, staged: boolean, };
|
||||
|
||||
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
|
||||
|
||||
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
|
||||
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BranchDeleteResult,
|
||||
CloneResult,
|
||||
GitBranchInfo,
|
||||
GitCommit,
|
||||
GitFileDiff,
|
||||
GitRemote,
|
||||
GitStatusSummary,
|
||||
GitWorktreeStatus,
|
||||
PullResult,
|
||||
PushResult,
|
||||
} from "./bindings/gen_git";
|
||||
@@ -26,6 +30,10 @@ export type DivergedStrategy = "force_reset" | "merge" | "cancel";
|
||||
|
||||
export type UncommittedChangesStrategy = "reset" | "cancel";
|
||||
|
||||
interface GitWatchResult {
|
||||
unlistenEvent: string;
|
||||
}
|
||||
|
||||
export interface GitCallbacks {
|
||||
addRemote: () => Promise<GitRemote | null>;
|
||||
promptCredentials: (
|
||||
@@ -38,13 +46,98 @@ export interface GitCallbacks {
|
||||
|
||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
|
||||
|
||||
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
||||
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
||||
const fetchAll = useQuery<void, string>({
|
||||
function gitWorktreeStatusQueryKey(dir?: string, refreshKey?: string) {
|
||||
return refreshKey == null
|
||||
? (["git", "worktree_status", dir] as const)
|
||||
: (["git", "worktree_status", dir, refreshKey] as const);
|
||||
}
|
||||
|
||||
export function invalidateGitWorktreeStatus(dir?: string) {
|
||||
return queryClient.invalidateQueries({ queryKey: gitWorktreeStatusQueryKey(dir) });
|
||||
}
|
||||
|
||||
export function useGitWorktreeStatus(dir: string, refreshKey?: string) {
|
||||
return useQuery<GitWorktreeStatus, string>({
|
||||
queryKey: gitWorktreeStatusQueryKey(dir, refreshKey),
|
||||
queryFn: () => invoke("cmd_git_worktree_status", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function watchGitWorktreeStatus(dir: string, callback: (status: GitWorktreeStatus) => void) {
|
||||
const channel = new Channel<GitWorktreeStatus>();
|
||||
channel.onmessage = callback;
|
||||
const unlistenPromise = invoke<GitWatchResult>("cmd_git_watch_worktree_status", {
|
||||
dir,
|
||||
channel,
|
||||
});
|
||||
|
||||
void unlistenPromise
|
||||
.then(({ unlistenEvent }) => {
|
||||
addGitWatchKey(unlistenEvent);
|
||||
})
|
||||
.catch(console.debug);
|
||||
|
||||
return () =>
|
||||
unlistenPromise
|
||||
.then(async ({ unlistenEvent }) => {
|
||||
unlistenGitWatcher(unlistenEvent);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function useGitFetchAll(dir: string, refreshKey?: string) {
|
||||
return useQuery<void, string>({
|
||||
queryKey: ["git", "fetch_all", dir, refreshKey],
|
||||
queryFn: () => invoke("cmd_git_fetch_all", { dir }),
|
||||
refetchInterval: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function useGitBranchInfoQuery(dir: string, refreshKey?: string, fetchAllUpdatedAt?: number) {
|
||||
return useQuery<GitBranchInfo, string>({
|
||||
refetchOnMount: true,
|
||||
queryKey: ["git", "branch_info", dir, refreshKey, fetchAllUpdatedAt],
|
||||
queryFn: () => invoke("cmd_git_branch_info", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGitBranchInfo(dir: string, refreshKey?: string) {
|
||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||
return useGitBranchInfoQuery(dir, refreshKey, fetchAll.dataUpdatedAt);
|
||||
}
|
||||
|
||||
export function useGitLog(dir: string, refreshKey?: string, relaPath?: string) {
|
||||
return useQuery<GitCommit[], string>({
|
||||
queryKey: ["git", "log", dir, refreshKey, relaPath],
|
||||
queryFn: () =>
|
||||
relaPath == null
|
||||
? invoke("cmd_git_log", { dir })
|
||||
: invoke("cmd_git_log_for_file", { dir, relaPath }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGitFileDiffForCommit(
|
||||
dir: string,
|
||||
relaPath: string,
|
||||
commitOid: string | null | undefined,
|
||||
) {
|
||||
return useQuery<GitFileDiff, string>({
|
||||
enabled: commitOid != null,
|
||||
queryKey: ["git", "file_diff_for_commit", dir, relaPath, commitOid],
|
||||
queryFn: () => {
|
||||
if (commitOid == null) throw new Error("Missing commit oid");
|
||||
return invoke("cmd_git_file_diff_for_commit", { dir, relaPath, commitOid });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
||||
const mutations = useGitMutations(dir, callbacks);
|
||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||
|
||||
return [
|
||||
{
|
||||
remotes: useQuery<GitRemote[], string>({
|
||||
@@ -52,11 +145,7 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
|
||||
queryFn: () => getRemotes(dir),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
log: useQuery<GitCommit[], string>({
|
||||
queryKey: ["git", "log", dir, refreshKey],
|
||||
queryFn: () => invoke("cmd_git_log", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
log: useGitLog(dir, refreshKey),
|
||||
status: useQuery<GitStatusSummary, string>({
|
||||
refetchOnMount: true,
|
||||
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
|
||||
@@ -68,6 +157,10 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useGitMutations(dir: string, callbacks: GitCallbacks) {
|
||||
return useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
||||
}
|
||||
|
||||
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
const push = async () => {
|
||||
const remotes = await getRemotes(dir);
|
||||
@@ -250,6 +343,20 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
mutationFn: () => invoke("cmd_git_reset_changes", { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
restore: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||
mutationKey: ["git", "restore", dir],
|
||||
mutationFn: (args) => invoke("cmd_git_restore_files", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
restoreFileFromCommit: createFastMutation<
|
||||
void,
|
||||
string,
|
||||
{ commitOid: string; relaPath: string }
|
||||
>({
|
||||
mutationKey: ["git", "restore-file-from-commit", dir],
|
||||
mutationFn: (args) => invoke("cmd_git_restore_file_from_commit", { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -257,6 +364,35 @@ async function getRemotes(dir: string) {
|
||||
return invoke<GitRemote[]>("cmd_git_remotes", { dir });
|
||||
}
|
||||
|
||||
function unlistenGitWatcher(unlistenEvent: string) {
|
||||
void emit(unlistenEvent).then(() => {
|
||||
removeGitWatchKey(unlistenEvent);
|
||||
});
|
||||
}
|
||||
|
||||
function getGitWatchKeys() {
|
||||
return sessionStorage.getItem("git-worktree-watchers")?.split(",").filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
function setGitWatchKeys(keys: string[]) {
|
||||
sessionStorage.setItem("git-worktree-watchers", keys.join(","));
|
||||
}
|
||||
|
||||
function addGitWatchKey(key: string) {
|
||||
const keys = getGitWatchKeys();
|
||||
setGitWatchKeys([...keys, key]);
|
||||
}
|
||||
|
||||
function removeGitWatchKey(key: string) {
|
||||
const keys = getGitWatchKeys();
|
||||
setGitWatchKeys(keys.filter((k) => k !== key));
|
||||
}
|
||||
|
||||
const gitWatchKeys = getGitWatchKeys();
|
||||
if (gitWatchKeys.length > 0) {
|
||||
gitWatchKeys.forEach(unlistenGitWatcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a git repository, prompting for credentials if needed.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ mod push;
|
||||
mod remotes;
|
||||
mod repository;
|
||||
mod reset;
|
||||
mod restore;
|
||||
mod status;
|
||||
mod unstage;
|
||||
mod util;
|
||||
@@ -29,10 +30,15 @@ pub use commit::git_commit;
|
||||
pub use credential::git_add_credential;
|
||||
pub use fetch::git_fetch_all;
|
||||
pub use init::git_init;
|
||||
pub use log::{GitCommit, git_log};
|
||||
pub use log::{GitCommit, GitFileDiff, git_file_diff_for_commit, git_log, git_log_for_file};
|
||||
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
||||
pub use push::{PushResult, git_push};
|
||||
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
||||
pub use repository::{GitRepositoryPaths, git_path_is_ignored, git_repository_paths};
|
||||
pub use reset::git_reset_changes;
|
||||
pub use status::{GitStatusSummary, git_status};
|
||||
pub use restore::{git_restore, git_restore_file_from_commit};
|
||||
pub use status::{
|
||||
GitBranchInfo, GitStatusSummary, GitWorktreeStatus, git_branch_info, git_status,
|
||||
git_worktree_status,
|
||||
};
|
||||
pub use unstage::git_unstage;
|
||||
|
||||
@@ -8,6 +8,7 @@ use ts_rs::TS;
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitCommit {
|
||||
pub oid: String,
|
||||
pub author: GitAuthor,
|
||||
pub when: DateTime<Utc>,
|
||||
pub message: Option<String>,
|
||||
@@ -21,7 +22,23 @@ pub struct GitAuthor {
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitFileDiff {
|
||||
pub original: String,
|
||||
pub modified: String,
|
||||
}
|
||||
|
||||
pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||
git_log_inner(dir, None)
|
||||
}
|
||||
|
||||
pub fn git_log_for_file(dir: &Path, rela_path: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||
git_log_inner(dir, Some(rela_path))
|
||||
}
|
||||
|
||||
fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::Result<Vec<GitCommit>> {
|
||||
let repo = open_repo(dir)?;
|
||||
|
||||
// Return empty if empty repo or no head (new repo)
|
||||
@@ -46,8 +63,16 @@ pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||
.filter_map(|oid| {
|
||||
let oid = filter_try!(oid);
|
||||
let commit = filter_try!(repo.find_commit(oid));
|
||||
if let Some(rela_path) = rela_path {
|
||||
let touches_path = filter_try!(commit_touches_path(&repo, &commit, rela_path));
|
||||
if !touches_path {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let author = commit.author();
|
||||
Some(GitCommit {
|
||||
oid: oid.to_string(),
|
||||
author: GitAuthor {
|
||||
name: author.name().map(|s| s.to_string()),
|
||||
email: author.email().map(|s| s.to_string()),
|
||||
@@ -61,6 +86,53 @@ pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
pub fn git_file_diff_for_commit(
|
||||
dir: &Path,
|
||||
commit_oid: &str,
|
||||
rela_path: &Path,
|
||||
) -> crate::error::Result<GitFileDiff> {
|
||||
let repo = open_repo(dir)?;
|
||||
let oid = git2::Oid::from_str(commit_oid)?;
|
||||
let commit = repo.find_commit(oid)?;
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
||||
|
||||
Ok(GitFileDiff {
|
||||
original: blob_text_at_path(&repo, old_tree.as_ref(), rela_path)?,
|
||||
modified: blob_text_at_path(&repo, Some(&new_tree), rela_path)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn commit_touches_path(
|
||||
repo: &git2::Repository,
|
||||
commit: &git2::Commit,
|
||||
rela_path: &Path,
|
||||
) -> crate::error::Result<bool> {
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
||||
|
||||
let mut opts = git2::DiffOptions::new();
|
||||
opts.pathspec(rela_path);
|
||||
|
||||
let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
|
||||
Ok(diff.deltas().len() > 0)
|
||||
}
|
||||
|
||||
fn blob_text_at_path(
|
||||
repo: &git2::Repository,
|
||||
tree: Option<&git2::Tree>,
|
||||
rela_path: &Path,
|
||||
) -> crate::error::Result<String> {
|
||||
let Some(tree) = tree else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let Ok(entry) = tree.get_path(rela_path) else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let blob = entry.to_object(repo)?.peel_to_blob()?;
|
||||
Ok(String::from_utf8(blob.content().to_vec())?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
|
||||
DateTime::from_timestamp(0, 0).unwrap()
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use crate::error::Error::{GitRepoNotFound, GitUnknown};
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitRepositoryPaths {
|
||||
pub workdir: PathBuf,
|
||||
pub gitdir: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
||||
match git2::Repository::discover(dir) {
|
||||
@@ -8,3 +15,17 @@ pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
||||
Err(e) => Err(GitUnknown(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_repository_paths(dir: &Path) -> Result<GitRepositoryPaths> {
|
||||
let repo = open_repo(dir)?;
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
|
||||
.to_path_buf();
|
||||
Ok(GitRepositoryPaths { workdir, gitdir: repo.path().to_path_buf() })
|
||||
}
|
||||
|
||||
pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result<bool> {
|
||||
let repo = open_repo(dir)?;
|
||||
Ok(repo.status_should_ignore(rela_path)?)
|
||||
}
|
||||
|
||||
76
crates/yaak-git/src/restore.rs
Normal file
76
crates/yaak-git/src/restore.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use log::info;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path};
|
||||
|
||||
pub fn git_restore(dir: &Path, rela_path: &Path) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
validate_relative_path(rela_path)?;
|
||||
|
||||
let status = repo.status_file(rela_path).ok();
|
||||
let is_untracked = status
|
||||
.is_some_and(|s| s.contains(git2::Status::WT_NEW) || s.contains(git2::Status::INDEX_NEW));
|
||||
|
||||
info!("Restoring file {rela_path:?} in {dir:?}");
|
||||
if is_untracked {
|
||||
let mut index = repo.index()?;
|
||||
let _ = index.remove_path(rela_path);
|
||||
index.write()?;
|
||||
|
||||
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let head = repo.head()?;
|
||||
let commit = head.peel_to_commit()?;
|
||||
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
|
||||
|
||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||
checkout.force().path(rela_path);
|
||||
repo.checkout_head(Some(&mut checkout))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_restore_file_from_commit(dir: &Path, commit_oid: &str, rela_path: &Path) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
validate_relative_path(rela_path)?;
|
||||
|
||||
let oid = git2::Oid::from_str(commit_oid)?;
|
||||
let commit = repo.find_commit(oid)?;
|
||||
let tree = commit.tree()?;
|
||||
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
||||
|
||||
info!("Restoring file {rela_path:?} from commit {commit_oid} in {dir:?}");
|
||||
if tree.get_path(rela_path).is_err() {
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||
checkout.force().path(rela_path);
|
||||
repo.checkout_tree(commit.as_object(), Some(&mut checkout))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_relative_path(path: &Path) -> Result<()> {
|
||||
let is_safe = !path.as_os_str().is_empty()
|
||||
&& !path.is_absolute()
|
||||
&& path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||
if is_safe {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::error::Error::GenericError(format!("Invalid restore path {}", path.display())))
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,20 @@ pub struct GitStatusSummary {
|
||||
pub behind: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitBranchInfo {
|
||||
pub path: String,
|
||||
pub head_ref: Option<String>,
|
||||
pub head_ref_shorthand: Option<String>,
|
||||
pub origins: Vec<String>,
|
||||
pub local_branches: Vec<String>,
|
||||
pub remote_branches: Vec<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
@@ -33,6 +47,23 @@ pub struct GitStatusEntry {
|
||||
pub next: Option<SyncModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitWorktreeStatus {
|
||||
pub entries: Vec<GitWorktreeStatusEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub struct GitWorktreeStatusEntry {
|
||||
pub rela_path: String,
|
||||
pub model_id: Option<String>,
|
||||
pub status: GitStatus,
|
||||
pub staged: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
@@ -46,31 +77,43 @@ pub enum GitStatus {
|
||||
TypeChange,
|
||||
}
|
||||
|
||||
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
|
||||
let repo = open_repo(dir)?;
|
||||
let mut opts = git2::StatusOptions::new();
|
||||
opts.include_ignored(false)
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.include_unmodified(false);
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
||||
let Some(rela_path) = entry.path() else {
|
||||
continue;
|
||||
};
|
||||
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
entries.push(GitWorktreeStatusEntry {
|
||||
rela_path: rela_path.to_string(),
|
||||
model_id: model_id_from_rela_path(Path::new(rela_path)),
|
||||
status,
|
||||
staged,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(GitWorktreeStatus { entries })
|
||||
}
|
||||
|
||||
pub fn git_branch_info(dir: &Path) -> crate::error::Result<GitBranchInfo> {
|
||||
let repo = open_repo(dir)?;
|
||||
git_branch_info_for_repo(&repo, dir)
|
||||
}
|
||||
|
||||
pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
let repo = open_repo(dir)?;
|
||||
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
|
||||
Ok(head) => {
|
||||
let tree = head.peel_to_tree().ok();
|
||||
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
||||
let head_ref = head.name().map(|s| s.to_string());
|
||||
|
||||
(tree, head_ref, head_ref_shorthand)
|
||||
}
|
||||
Err(_) => {
|
||||
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
||||
// See https://github.com/starship/starship/pull/1336
|
||||
let head_path = repo.path().join("HEAD");
|
||||
let head_ref = fs::read_to_string(&head_path)
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.next()
|
||||
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
||||
let head_ref_shorthand =
|
||||
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
||||
(None, head_ref, head_ref_shorthand)
|
||||
}
|
||||
};
|
||||
let branch_info = git_branch_info_for_repo(&repo, dir)?;
|
||||
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
|
||||
|
||||
let mut opts = git2::StatusOptions::new();
|
||||
opts.include_ignored(false)
|
||||
@@ -83,51 +126,8 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
let mut entries: Vec<GitStatusEntry> = Vec::new();
|
||||
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
||||
let rela_path = entry.path().unwrap().to_string();
|
||||
let status = entry.status();
|
||||
let index_status = match status {
|
||||
// Note: order matters here, since we're checking a bitmap!
|
||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
||||
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
||||
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
||||
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
||||
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||
s => {
|
||||
warn!("Unknown index status {s:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let worktree_status = match status {
|
||||
// Note: order matters here, since we're checking a bitmap!
|
||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
||||
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
||||
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
||||
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
||||
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||
s => {
|
||||
warn!("Unknown worktree status {s:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let status = if index_status == GitStatus::Current {
|
||||
worktree_status.clone()
|
||||
} else {
|
||||
index_status.clone()
|
||||
};
|
||||
|
||||
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
|
||||
{
|
||||
// No change, so can't be added
|
||||
false
|
||||
} else if index_status != GitStatus::Current {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get previous content from Git, if it's in there
|
||||
@@ -158,9 +158,27 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
})
|
||||
}
|
||||
|
||||
Ok(GitStatusSummary {
|
||||
entries,
|
||||
path: branch_info.path,
|
||||
head_ref: branch_info.head_ref,
|
||||
head_ref_shorthand: branch_info.head_ref_shorthand,
|
||||
origins: branch_info.origins,
|
||||
local_branches: branch_info.local_branches,
|
||||
remote_branches: branch_info.remote_branches,
|
||||
ahead: branch_info.ahead,
|
||||
behind: branch_info.behind,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_branch_info_for_repo(
|
||||
repo: &git2::Repository,
|
||||
dir: &Path,
|
||||
) -> crate::error::Result<GitBranchInfo> {
|
||||
let (head_ref, head_ref_shorthand) = git_head_refs(repo);
|
||||
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
||||
let local_branches = local_branch_names(&repo)?;
|
||||
let remote_branches = remote_branch_names(&repo)?;
|
||||
let local_branches = local_branch_names(repo)?;
|
||||
let remote_branches = remote_branch_names(repo)?;
|
||||
|
||||
// Compute ahead/behind relative to remote tracking branch
|
||||
let (ahead, behind) = (|| -> Option<(usize, usize)> {
|
||||
@@ -174,15 +192,85 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
})()
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
Ok(GitStatusSummary {
|
||||
entries,
|
||||
origins,
|
||||
Ok(GitBranchInfo {
|
||||
path: dir.to_string_lossy().to_string(),
|
||||
head_ref,
|
||||
head_ref_shorthand,
|
||||
origins,
|
||||
local_branches,
|
||||
remote_branches,
|
||||
ahead: ahead as u32,
|
||||
behind: behind as u32,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_head_refs(repo: &git2::Repository) -> (Option<String>, Option<String>) {
|
||||
match repo.head() {
|
||||
Ok(head) => {
|
||||
let head_ref = head.name().map(|s| s.to_string());
|
||||
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
||||
(head_ref, head_ref_shorthand)
|
||||
}
|
||||
Err(_) => {
|
||||
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
||||
// See https://github.com/starship/starship/pull/1336
|
||||
let head_path = repo.path().join("HEAD");
|
||||
let head_ref = fs::read_to_string(&head_path)
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.next()
|
||||
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
||||
let head_ref_shorthand =
|
||||
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
||||
(head_ref, head_ref_shorthand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> {
|
||||
let index_status = match status {
|
||||
// Note: order matters here, since we're checking a bitmap!
|
||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
||||
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
||||
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
||||
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
||||
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||
s => {
|
||||
warn!("Unknown index status {s:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let worktree_status = match status {
|
||||
// Note: order matters here, since we're checking a bitmap!
|
||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
||||
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
||||
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
||||
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
||||
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||
s => {
|
||||
warn!("Unknown worktree status {s:?}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let status =
|
||||
if index_status == GitStatus::Current { worktree_status } else { index_status.clone() };
|
||||
let staged = index_status != GitStatus::Current;
|
||||
|
||||
Some((status, staged))
|
||||
}
|
||||
|
||||
fn model_id_from_rela_path(path: &Path) -> Option<String> {
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if ext != "yaml" && ext != "yml" && ext != "json" {
|
||||
return None;
|
||||
}
|
||||
|
||||
path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from)
|
||||
}
|
||||
|
||||
@@ -304,7 +304,10 @@ async fn build_binary_body(
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_text_body(body: &BTreeMap<String, serde_json::Value>, body_type: &str) -> Option<SendableBodyWithMeta> {
|
||||
fn build_text_body(
|
||||
body: &BTreeMap<String, serde_json::Value>,
|
||||
body_type: &str,
|
||||
) -> Option<SendableBodyWithMeta> {
|
||||
let text = get_str_map(body, "text");
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
|
||||
@@ -16,8 +16,8 @@ use std::collections::HashMap;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::str::FromStr;
|
||||
use ts_rs::TS;
|
||||
use yaak_database::{Result as DbResult, UpdateSource};
|
||||
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
||||
use yaak_database::{UpdateSource, Result as DbResult};
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! impl_model {
|
||||
@@ -2526,4 +2526,3 @@ impl AnyModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::connection_or_tx::ConnectionOrTx;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::connection_or_tx::ConnectionOrTx;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest,
|
||||
|
||||
@@ -16,7 +16,10 @@ impl<'a> ClientDb<'a> {
|
||||
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
|
||||
)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok()
|
||||
self.conn()
|
||||
.resolve()
|
||||
.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn set_plugin_key_value(
|
||||
|
||||
@@ -10,7 +10,9 @@ use std::collections::BTreeMap;
|
||||
use ts_rs::TS;
|
||||
use yaak_core::WorkspaceContext;
|
||||
|
||||
pub use yaak_database::{ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id};
|
||||
pub use yaak_database::{
|
||||
ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -79,10 +79,9 @@ where
|
||||
let len = data.len();
|
||||
self.bytes_count += len as u64;
|
||||
self.chunks.push(data.clone());
|
||||
let _ = self.event_tx.send(ProxyEvent::ResponseBodyChunk {
|
||||
id: self.request_id,
|
||||
bytes: len,
|
||||
});
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(ProxyEvent::ResponseBodyChunk { id: self.request_id, bytes: len });
|
||||
}
|
||||
Poll::Ready(Some(Ok(frame)))
|
||||
}
|
||||
|
||||
@@ -18,23 +18,14 @@ impl CertificateAuthority {
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
|
||||
params.key_usages.push(KeyUsagePurpose::CrlSign);
|
||||
params
|
||||
.distinguished_name
|
||||
.push(rcgen::DnType::CommonName, "Debug Proxy CA");
|
||||
params
|
||||
.distinguished_name
|
||||
.push(rcgen::DnType::OrganizationName, "Debug Proxy");
|
||||
params.distinguished_name.push(rcgen::DnType::CommonName, "Debug Proxy CA");
|
||||
params.distinguished_name.push(rcgen::DnType::OrganizationName, "Debug Proxy");
|
||||
|
||||
let key = KeyPair::generate()?;
|
||||
let ca_cert = params.self_signed(&key)?;
|
||||
let ca_cert_der = ca_cert.der().clone();
|
||||
|
||||
Ok(Self {
|
||||
ca_cert,
|
||||
ca_cert_der,
|
||||
ca_key: key,
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
})
|
||||
Ok(Self { ca_cert, ca_cert_der, ca_key: key, cache: Mutex::new(HashMap::new()) })
|
||||
}
|
||||
|
||||
pub fn ca_pem(&self) -> String {
|
||||
@@ -53,9 +44,7 @@ impl CertificateAuthority {
|
||||
}
|
||||
|
||||
let mut params = CertificateParams::new(vec![domain.to_string()])?;
|
||||
params
|
||||
.distinguished_name
|
||||
.push(rcgen::DnType::CommonName, domain);
|
||||
params.distinguished_name.push(rcgen::DnType::CommonName, domain);
|
||||
|
||||
let leaf_key = KeyPair::generate()?;
|
||||
let leaf_cert = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key)?;
|
||||
@@ -63,20 +52,18 @@ impl CertificateAuthority {
|
||||
let cert_der = leaf_cert.der().clone();
|
||||
let key_der = leaf_key.serialize_der();
|
||||
|
||||
let mut config = ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()?
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![cert_der, self.ca_cert_der.clone()],
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
|
||||
)?;
|
||||
let mut config =
|
||||
ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()?
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![cert_der, self.ca_cert_der.clone()],
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
|
||||
)?;
|
||||
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||
|
||||
let config = Arc::new(config);
|
||||
self.cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(domain.to_string(), config.clone());
|
||||
self.cache.lock().unwrap().insert(domain.to_string(), config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
|
||||
@@ -4,9 +4,9 @@ mod connection;
|
||||
mod request;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cert::CertificateAuthority;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -27,7 +27,11 @@ pub enum ProxyEvent {
|
||||
http_version: String,
|
||||
},
|
||||
/// A request header sent to the upstream server.
|
||||
RequestHeader { id: u64, name: String, value: String },
|
||||
RequestHeader {
|
||||
id: u64,
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
/// The full request body (buffered before forwarding).
|
||||
RequestBody { id: u64, body: Vec<u8> },
|
||||
/// Response headers received from upstream.
|
||||
@@ -38,7 +42,11 @@ pub enum ProxyEvent {
|
||||
elapsed_ms: u64,
|
||||
},
|
||||
/// A response header received from the upstream server.
|
||||
ResponseHeader { id: u64, name: String, value: String },
|
||||
ResponseHeader {
|
||||
id: u64,
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
/// A chunk of the response body was received (emitted per-frame).
|
||||
ResponseBodyChunk { id: u64, bytes: usize },
|
||||
/// The response body stream has completed.
|
||||
|
||||
@@ -63,10 +63,7 @@ fn emit_request_events(
|
||||
});
|
||||
}
|
||||
if let Some(body) = body {
|
||||
let _ = tx.send(ProxyEvent::RequestBody {
|
||||
id,
|
||||
body: body.clone(),
|
||||
});
|
||||
let _ = tx.send(ProxyEvent::RequestBody { id, body: body.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,22 +120,13 @@ async fn handle_http(
|
||||
let http_version = version_str(req.version());
|
||||
let start = Instant::now();
|
||||
|
||||
let _ = event_tx.send(ProxyEvent::RequestStart {
|
||||
id,
|
||||
method,
|
||||
url: uri.clone(),
|
||||
http_version,
|
||||
});
|
||||
let _ = event_tx.send(ProxyEvent::RequestStart { id, method, url: uri.clone(), http_version });
|
||||
|
||||
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
|
||||
|
||||
let (parts, body) = req.into_parts();
|
||||
let body_bytes = body.collect().await?.to_bytes();
|
||||
let request_body = if body_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body_bytes.to_vec())
|
||||
};
|
||||
let request_body = if body_bytes.is_empty() { None } else { Some(body_bytes.to_vec()) };
|
||||
emit_request_events(&event_tx, id, &parts.headers, &request_body);
|
||||
|
||||
let outgoing_req = Request::from_parts(parts, Full::new(body_bytes));
|
||||
@@ -148,16 +136,10 @@ async fn handle_http(
|
||||
emit_response_events(&event_tx, id, &resp, &start);
|
||||
|
||||
let (parts, body) = resp.into_parts();
|
||||
Ok(Response::from_parts(
|
||||
parts,
|
||||
measured_incoming(body, id, start, event_tx),
|
||||
))
|
||||
Ok(Response::from_parts(parts, measured_incoming(body, id, start, event_tx)))
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(ProxyEvent::Error {
|
||||
id,
|
||||
error: e.to_string(),
|
||||
});
|
||||
let _ = event_tx.send(ProxyEvent::Error { id, error: e.to_string() });
|
||||
Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
|
||||
}
|
||||
}
|
||||
@@ -168,11 +150,7 @@ async fn handle_connect(
|
||||
event_tx: std_mpsc::Sender<ProxyEvent>,
|
||||
ca: Arc<CertificateAuthority>,
|
||||
) -> Result<Response<BoxBody>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let authority = req
|
||||
.uri()
|
||||
.authority()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_default();
|
||||
let authority = req.uri().authority().map(|a| a.to_string()).unwrap_or_default();
|
||||
let (host, port) = parse_host_port(&authority);
|
||||
|
||||
let server_config = ca.server_config(&host)?;
|
||||
@@ -189,10 +167,7 @@ async fn handle_connect(
|
||||
}
|
||||
};
|
||||
|
||||
let tls_stream = match acceptor
|
||||
.accept(hyper_util::rt::TokioIo::new(upgraded))
|
||||
.await
|
||||
{
|
||||
let tls_stream = match acceptor.accept(hyper_util::rt::TokioIo::new(upgraded)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("TLS accept failed for {host}: {e}");
|
||||
@@ -203,10 +178,7 @@ async fn handle_connect(
|
||||
let tx = event_tx.clone();
|
||||
let host_for_requests = host.clone();
|
||||
let mut builder = auto::Builder::new(TokioExecutor::new());
|
||||
builder
|
||||
.http1()
|
||||
.preserve_header_case(true)
|
||||
.title_case_headers(true);
|
||||
builder.http1().preserve_header_case(true).title_case_headers(true);
|
||||
if let Err(e) = builder
|
||||
.serve_connection_with_upgrades(
|
||||
hyper_util::rt::TokioIo::new(tls_stream),
|
||||
@@ -271,20 +243,12 @@ async fn forward_https(
|
||||
let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let method = req.method().to_string();
|
||||
let http_version = version_str(req.version());
|
||||
let path = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|pq| pq.to_string())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into());
|
||||
let uri_str = format!("https://{host}{path}");
|
||||
let start = Instant::now();
|
||||
|
||||
let _ = event_tx.send(ProxyEvent::RequestStart {
|
||||
id,
|
||||
method,
|
||||
url: uri_str.clone(),
|
||||
http_version,
|
||||
});
|
||||
let _ =
|
||||
event_tx.send(ProxyEvent::RequestStart { id, method, url: uri_str.clone(), http_version });
|
||||
|
||||
// Connect to upstream with TLS
|
||||
let tcp_stream = TcpStream::connect(target_addr).await?;
|
||||
@@ -305,18 +269,13 @@ async fn forward_https(
|
||||
let server_name = ServerName::try_from(host.to_string())?;
|
||||
let tls_stream = connector.connect(server_name, tcp_stream).await?;
|
||||
|
||||
let negotiated_h2 = tls_stream
|
||||
.get_ref()
|
||||
.1
|
||||
.alpn_protocol()
|
||||
.map_or(false, |p| p == b"h2");
|
||||
let negotiated_h2 = tls_stream.get_ref().1.alpn_protocol().map_or(false, |p| p == b"h2");
|
||||
|
||||
let io = hyper_util::rt::TokioIo::new(tls_stream);
|
||||
|
||||
let mut sender = if negotiated_h2 {
|
||||
let (sender, conn) = hyper::client::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.handshake(io)
|
||||
.await?;
|
||||
let (sender, conn) =
|
||||
hyper::client::conn::http2::Builder::new(TokioExecutor::new()).handshake(io).await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
eprintln!("Upstream h2 connection error: {e}");
|
||||
@@ -340,11 +299,7 @@ async fn forward_https(
|
||||
// Capture request metadata
|
||||
let (mut parts, body) = req.into_parts();
|
||||
let body_bytes = body.collect().await?.to_bytes();
|
||||
let request_body = if body_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body_bytes.to_vec())
|
||||
};
|
||||
let request_body = if body_bytes.is_empty() { None } else { Some(body_bytes.to_vec()) };
|
||||
emit_request_events(&event_tx, id, &parts.headers, &request_body);
|
||||
|
||||
if negotiated_h2 {
|
||||
@@ -365,16 +320,10 @@ async fn forward_https(
|
||||
emit_response_events(&event_tx, id, &resp, &start);
|
||||
|
||||
let (parts, body) = resp.into_parts();
|
||||
Ok(Response::from_parts(
|
||||
parts,
|
||||
measured_incoming(body, id, start, event_tx),
|
||||
))
|
||||
Ok(Response::from_parts(parts, measured_incoming(body, id, start, event_tx)))
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(ProxyEvent::Error {
|
||||
id,
|
||||
error: e.to_string(),
|
||||
});
|
||||
let _ = event_tx.send(ProxyEvent::Error { id, error: e.to_string() });
|
||||
Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod error;
|
||||
pub mod escape;
|
||||
pub mod format_json;
|
||||
pub mod strip_json_comments;
|
||||
pub mod parser;
|
||||
pub mod renderer;
|
||||
pub mod strip_json_comments;
|
||||
pub mod wasm;
|
||||
|
||||
pub use parser::*;
|
||||
|
||||
@@ -113,11 +113,8 @@ pub fn strip_json_comments(text: &str) -> String {
|
||||
}
|
||||
|
||||
// Remove lines that are now empty (were comment-only lines)
|
||||
let result = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
let result =
|
||||
result.lines().filter(|line| !line.trim().is_empty()).collect::<Vec<&str>>().join("\n");
|
||||
|
||||
// Remove trailing commas before } or ]
|
||||
strip_trailing_commas(&result)
|
||||
@@ -192,10 +189,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_trailing_line_comment() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{
|
||||
strip_json_comments(
|
||||
r#"{
|
||||
"foo": "bar", // this is a comment
|
||||
"baz": 123
|
||||
}"#),
|
||||
}"#
|
||||
),
|
||||
r#"{
|
||||
"foo": "bar",
|
||||
"baz": 123
|
||||
@@ -206,10 +205,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_whole_line_comment() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{
|
||||
strip_json_comments(
|
||||
r#"{
|
||||
// this is a comment
|
||||
"foo": "bar"
|
||||
}"#),
|
||||
}"#
|
||||
),
|
||||
r#"{
|
||||
"foo": "bar"
|
||||
}"#
|
||||
@@ -219,9 +220,11 @@ mod tests {
|
||||
#[test]
|
||||
fn test_inline_block_comment() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{
|
||||
strip_json_comments(
|
||||
r#"{
|
||||
"foo": /* a comment */ "bar"
|
||||
}"#),
|
||||
}"#
|
||||
),
|
||||
r#"{
|
||||
"foo": "bar"
|
||||
}"#
|
||||
@@ -231,10 +234,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_whole_line_block_comment() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{
|
||||
strip_json_comments(
|
||||
r#"{
|
||||
/* a comment */
|
||||
"foo": "bar"
|
||||
}"#),
|
||||
}"#
|
||||
),
|
||||
r#"{
|
||||
"foo": "bar"
|
||||
}"#
|
||||
@@ -244,12 +249,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_multiline_block_comment() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{
|
||||
strip_json_comments(
|
||||
r#"{
|
||||
/**
|
||||
* Hello World!
|
||||
*/
|
||||
"foo": "bar"
|
||||
}"#),
|
||||
}"#
|
||||
),
|
||||
r#"{
|
||||
"foo": "bar"
|
||||
}"#
|
||||
@@ -276,12 +283,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_multiple_comments() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{
|
||||
strip_json_comments(
|
||||
r#"{
|
||||
// first comment
|
||||
"foo": "bar", // trailing
|
||||
/* block */
|
||||
"baz": 123
|
||||
}"#),
|
||||
}"#
|
||||
),
|
||||
r#"{
|
||||
"foo": "bar",
|
||||
"baz": 123
|
||||
@@ -292,10 +301,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_trailing_comma_after_comment_removed() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{
|
||||
strip_json_comments(
|
||||
r#"{
|
||||
"a": "aaa",
|
||||
// "b": "bbb"
|
||||
}"#),
|
||||
}"#
|
||||
),
|
||||
r#"{
|
||||
"a": "aaa"
|
||||
}"#
|
||||
@@ -304,10 +315,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_trailing_comma_in_array() {
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"[1, 2, /* 3 */]"#),
|
||||
r#"[1, 2]"#
|
||||
);
|
||||
assert_eq!(strip_json_comments(r#"[1, 2, /* 3 */]"#), r#"[1, 2]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,9 @@ use log::info;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_models::models::{Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||
use yaak_models::models::{
|
||||
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
|
||||
};
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
|
||||
225
package-lock.json
generated
225
package-lock.json
generated
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@tauri-apps/cli": "^2.11.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@yaakapp/cli": "^0.5.1",
|
||||
@@ -119,14 +119,14 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-router": "^1.133.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.4",
|
||||
"@tauri-apps/plugin-log": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.3",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.5.1",
|
||||
"cm6-graphql": "^0.2.1",
|
||||
@@ -140,6 +140,7 @@
|
||||
"hexy": "^0.3.5",
|
||||
"history": "^5.3.0",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
@@ -185,7 +186,7 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"decompress": "^4.2.1",
|
||||
"internal-ip": "^8.0.0",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-nesting": "^13.0.2",
|
||||
"rollup": "^4.60.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
@@ -222,54 +223,6 @@
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"apps/yaak-client/node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"apps/yaak-client/node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"apps/yaak-client/node_modules/uuid": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
@@ -288,7 +241,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@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",
|
||||
@@ -296,6 +249,7 @@
|
||||
"@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"
|
||||
@@ -4221,9 +4175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -4231,9 +4185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
|
||||
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz",
|
||||
"integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -4247,23 +4201,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.9.6",
|
||||
"@tauri-apps/cli-darwin-x64": "2.9.6",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
|
||||
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz",
|
||||
"integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4278,9 +4232,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
|
||||
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz",
|
||||
"integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4295,9 +4249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
|
||||
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz",
|
||||
"integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4312,9 +4266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
|
||||
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz",
|
||||
"integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4329,9 +4283,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
|
||||
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz",
|
||||
"integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4346,9 +4300,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
|
||||
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz",
|
||||
"integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4363,9 +4317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
|
||||
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz",
|
||||
"integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4380,9 +4334,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
|
||||
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz",
|
||||
"integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4397,9 +4351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
|
||||
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz",
|
||||
"integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4414,9 +4368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
|
||||
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz",
|
||||
"integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -4431,9 +4385,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
|
||||
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz",
|
||||
"integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4457,21 +4411,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.5.0.tgz",
|
||||
"integrity": "sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==",
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-log": {
|
||||
@@ -4484,12 +4438,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==",
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-os": {
|
||||
@@ -4502,12 +4456,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.4.tgz",
|
||||
"integrity": "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==",
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aws4": {
|
||||
@@ -7975,9 +7929,9 @@
|
||||
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9781,6 +9735,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jotai-family": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jotai-family/-/jotai-family-1.0.1.tgz",
|
||||
"integrity": "sha512-Zb/79GNDhC/z82R+6qTTpeKW4l4H6ZCApfF5W8G4SH37E4mhbysU7r8DkP0KX94hWvjB/6lt/97nSr3wB+64Zg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jotai": ">=2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
||||
@@ -16989,14 +16955,17 @@
|
||||
"name": "@yaakapp-internal/theme",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@yaakapp-internal/plugins": "^1.0.0",
|
||||
"parse-color": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@yaakapp-internal/ui",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"jotai-family": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"plugins-external/faker": {
|
||||
"name": "@yaak/faker",
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@tauri-apps/cli": "^2.11.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@yaakapp/cli": "^0.5.1",
|
||||
|
||||
36
packages/common-lib/eagerDebounceAsync.ts
Normal file
36
packages/common-lib/eagerDebounceAsync.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function eagerDebounceAsync(fn: () => Promise<void>, delay: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let runAfterInFlight = false;
|
||||
|
||||
const run = async () => {
|
||||
if (inFlight != null) {
|
||||
runAfterInFlight = true;
|
||||
return;
|
||||
}
|
||||
|
||||
runAfterInFlight = false;
|
||||
inFlight = fn()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
if (runAfterInFlight && timer == null) {
|
||||
void run();
|
||||
}
|
||||
});
|
||||
await inFlight;
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (timer == null) {
|
||||
void run();
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void run();
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./debounce";
|
||||
export * from "./eagerDebounceAsync";
|
||||
export * from "./formatSize";
|
||||
export * from "./templateFunction";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@yaakapp-internal/plugins": "^1.0.0",
|
||||
"parse-color": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"jotai-family": "^1.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ function TreeItem_<T extends { id: string }>({
|
||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [dropHover, setDropHover] = useState<null | "drop" | "animate">(null);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const startedHoverTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handle = useMemo<TreeItemHandle>(
|
||||
() => ({
|
||||
focus: () => {
|
||||
@@ -141,7 +141,13 @@ function TreeItem_<T extends { id: string }>({
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async (el: HTMLInputElement) => {
|
||||
getEditOptions?.(node.item).onChange(node.item, el.value);
|
||||
const editOptions = getEditOptions?.(node.item);
|
||||
if (editOptions == null || el.value === editOptions.defaultValue) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
editOptions.onChange(node.item, el.value);
|
||||
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
|
||||
// Slight delay for the model to propagate to the local store
|
||||
setTimeout(() => setEditing(false), 200);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily, selectAtom } from "jotai/utils";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const selectedIdsFamily = atomFamily((_treeId: string) => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineConfig } from "vite-plus";
|
||||
|
||||
export default defineConfig({
|
||||
staged: {
|
||||
"*": "vp check --fix",
|
||||
},
|
||||
lint: {
|
||||
ignorePatterns: ["npm/**", "crates/yaak-templates/pkg/**", "**/bindings/gen_*.ts"],
|
||||
options: {
|
||||
|
||||
Reference in New Issue
Block a user