Skip to content

Commit d7e67cf

Browse files
authored
Add live git status indicators (#458)
1 parent 1b154ba commit d7e67cf

35 files changed

Lines changed: 1672 additions & 548 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/yaak-client/components/EnvironmentEditDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from "@yaakapp-internal/models";
33
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
44
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
55
import { atom, useAtomValue } from "jotai";
6-
import { atomFamily } from "jotai/utils";
6+
import { atomFamily } from "jotai-family";
77
import { useCallback, useLayoutEffect, useRef, useState } from "react";
88
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
99
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";

apps/yaak-client/components/Sidebar.tsx

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Extension } from "@codemirror/state";
22
import { Compartment } from "@codemirror/state";
33
import { debounce } from "@yaakapp-internal/lib";
4+
import { gitMutations } from "@yaakapp-internal/git";
5+
import type { GitStatus } from "@yaakapp-internal/git";
46
import type {
57
AnyModel,
68
Folder,
@@ -23,13 +25,18 @@ import {
2325
} from "@yaakapp-internal/models";
2426
import classNames from "classnames";
2527
import { atom, useAtomValue } from "jotai";
26-
import { atomFamily, selectAtom } from "jotai/utils";
28+
import { atomFamily } from "jotai-family";
29+
import { selectAtom } from "jotai/utils";
2730
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
2831
import { moveToWorkspace } from "../commands/moveToWorkspace";
2932
import { openFolderSettings } from "../commands/openFolderSettings";
3033
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
3134
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
32-
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
35+
import {
36+
activeWorkspaceAtom,
37+
activeWorkspaceIdAtom,
38+
activeWorkspaceMetaAtom,
39+
} from "../hooks/useActiveWorkspace";
3340
import { allRequestsAtom } from "../hooks/useAllRequests";
3441
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
3542
import { getFolderActions } from "../hooks/useFolderActions";
@@ -42,7 +49,13 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
4249
import { useSidebarHidden } from "../hooks/useSidebarHidden";
4350
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
4451
import { deepEqualAtom } from "../lib/atoms";
52+
import { showConfirm } from "../lib/confirm";
4553
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
54+
import { showDialog } from "../lib/dialog";
55+
import {
56+
gitWorktreeStatusByModelIdAtom,
57+
gitWorktreeStatusFamily,
58+
} from "../lib/gitWorktreeStatus";
4659
import { jotaiStore } from "../lib/jotai";
4760
import { resolvedModelName } from "../lib/resolvedModelName";
4861
import { isSidebarFocused } from "../lib/scopes";
@@ -68,6 +81,9 @@ import type { InputHandle } from "./core/Input";
6881
import { Input } from "./core/Input";
6982
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
7083
import { GitDropdown } from "./git/GitDropdown";
84+
import { gitCallbacks } from "./git/callbacks";
85+
import { FileHistoryDialog } from "./git/FileHistoryDialog";
86+
import { sync } from "../init/sync";
7187

7288
const collapsedFamily = atomFamily((treeId: string) => {
7389
const key = ["sidebar_collapsed", treeId ?? "n/a"];
@@ -375,6 +391,8 @@ function Sidebar({ className }: { className?: string }) {
375391
}
376392

377393
const workspaces = jotaiStore.get(workspacesAtom);
394+
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir;
395+
const gitItems = getGitContextMenuItems({ items, syncDir });
378396
const onlyHttpRequests = items.every((i) => i.model === "http_request");
379397
const requestItems = items.filter(
380398
(i) =>
@@ -458,8 +476,10 @@ function Sidebar({ className }: { className?: string }) {
458476
...initialItems,
459477
{
460478
type: "separator",
461-
hidden: initialItems.filter((v) => !v.hidden).length === 0,
479+
hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0,
462480
},
481+
...gitItems,
482+
{ type: "separator", hidden: gitItems.length === 0 },
463483
{
464484
label: "Rename",
465485
leftSlot: <Icon icon="pencil" />,
@@ -661,6 +681,73 @@ function Sidebar({ className }: { className?: string }) {
661681

662682
export default Sidebar;
663683

684+
function getGitContextMenuItems({
685+
items,
686+
syncDir,
687+
}: {
688+
items: SidebarModel[];
689+
syncDir: string | null | undefined;
690+
}): DropdownItem[] {
691+
if (syncDir == null) return [];
692+
693+
const gitStatusEntries = items.flatMap((item) => {
694+
const status = jotaiStore.get(gitWorktreeStatusFamily(item.id));
695+
return status == null || status.status === "current" ? [] : [status];
696+
});
697+
const historyItem = items.length === 1 ? items[0] : null;
698+
const historyPath =
699+
historyItem == null
700+
? null
701+
: (jotaiStore.get(gitWorktreeStatusFamily(historyItem.id))?.relaPath ??
702+
syncPathForModel(historyItem));
703+
704+
return [
705+
{
706+
label: "View History",
707+
leftSlot: <Icon icon="history" />,
708+
hidden: historyPath == null,
709+
onSelect: () => {
710+
if (historyPath == null) return;
711+
showDialog({
712+
id: "git-history",
713+
size: "lg",
714+
title: "File History",
715+
noPadding: true,
716+
noScroll: true,
717+
render: () => <FileHistoryDialog dir={syncDir} relaPath={historyPath} />,
718+
});
719+
},
720+
},
721+
{
722+
label: "Restore Changes",
723+
leftSlot: <Icon icon="rotate_ccw" />,
724+
hidden: gitStatusEntries.length === 0,
725+
async onSelect() {
726+
const confirmed = await showConfirm({
727+
id: "git-restore-sidebar-items",
728+
title: "Restore Changes",
729+
description:
730+
gitStatusEntries.length === 1
731+
? "This will discard uncommitted changes for the selected item."
732+
: `This will discard uncommitted changes for ${gitStatusEntries.length} selected items.`,
733+
confirmText: "Restore",
734+
color: "danger",
735+
});
736+
if (!confirmed) return;
737+
738+
await gitMutations(syncDir, gitCallbacks(syncDir)).restore.mutateAsync({
739+
relaPaths: gitStatusEntries.map((entry) => entry.relaPath),
740+
});
741+
await sync({ force: true });
742+
},
743+
},
744+
];
745+
}
746+
747+
function syncPathForModel(item: SidebarModel) {
748+
return `yaak.${item.id}.yaml`;
749+
}
750+
664751
const activeIdAtom = atom<string | null>((get) => {
665752
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
666753
});
@@ -790,6 +877,64 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
790877
return [root, fields] as const;
791878
});
792879

880+
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
881+
const allModels = get(memoAllPotentialChildrenAtom);
882+
const activeWorkspace = get(activeWorkspaceAtom);
883+
const gitStatusByModelId = get(gitWorktreeStatusByModelIdAtom);
884+
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
885+
const statusByModelId: Record<string, GitStatus> = {};
886+
887+
for (const item of allModels) {
888+
if ("folderId" in item && item.folderId == null) {
889+
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
890+
childrenMap[item.workspaceId]?.push(item);
891+
} else if ("folderId" in item && item.folderId != null) {
892+
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
893+
childrenMap[item.folderId]?.push(item);
894+
}
895+
}
896+
897+
const visit = (item: SidebarModel): GitStatus | null => {
898+
const statuses: GitStatus[] = [];
899+
const directStatus = gitStatusByModelId[item.id]?.status;
900+
if (directStatus != null && directStatus !== "current") {
901+
statuses.push(directStatus);
902+
}
903+
904+
for (const child of childrenMap[item.id] ?? []) {
905+
const childStatus = visit(child);
906+
if (childStatus != null) statuses.push(childStatus);
907+
}
908+
909+
const status = summarizeGitStatuses(statuses);
910+
if (status != null) {
911+
statusByModelId[item.id] = status;
912+
}
913+
return status;
914+
};
915+
916+
if (activeWorkspace != null) {
917+
visit(activeWorkspace);
918+
}
919+
920+
return statusByModelId;
921+
});
922+
923+
const sidebarGitStatusFamily = atomFamily(
924+
(modelId: string) =>
925+
selectAtom(sidebarGitStatusByModelIdAtom, (statusByModelId) => statusByModelId[modelId] ?? null),
926+
Object.is,
927+
);
928+
929+
function summarizeGitStatuses(statuses: GitStatus[]): GitStatus | null {
930+
if (statuses.length === 0) return null;
931+
const firstStatus = statuses[0];
932+
if (firstStatus != null && statuses.every((status) => status === firstStatus)) {
933+
return firstStatus;
934+
}
935+
return "modified";
936+
}
937+
793938
function getItemKey(item: SidebarModel) {
794939
const responses = jotaiStore.get(httpResponsesAtom);
795940
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
@@ -836,6 +981,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
836981
treeId: string;
837982
item: SidebarModel;
838983
}) {
984+
const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
839985
const response = useAtomValue(
840986
useMemo(
841987
() =>
@@ -854,7 +1000,16 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
8541000

8551001
return (
8561002
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
857-
<div className="truncate">{resolvedModelName(item)}</div>
1003+
<div
1004+
className={classNames(
1005+
"truncate",
1006+
gitStatus === "modified" && "text-info",
1007+
gitStatus === "untracked" && "text-success",
1008+
gitStatus === "removed" && "text-danger",
1009+
)}
1010+
>
1011+
{resolvedModelName(item)}
1012+
</div>
8581013
{response != null && (
8591014
<div className="ml-auto">
8601015
{response.state !== "closed" ? (

apps/yaak-client/components/core/HttpResponseDurationTag.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ interface Props {
77

88
export function HttpResponseDurationTag({ response }: Props) {
99
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
10-
const timeout = useRef<NodeJS.Timeout>(undefined);
10+
const timeout = useRef<ReturnType<typeof setInterval>>(undefined);
1111

1212
// Calculate the duration of the response for use when the response hasn't finished yet
1313
useEffect(() => {

apps/yaak-client/components/core/Tooltip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
3131
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
3232
const triggerRef = useRef<HTMLButtonElement>(null);
3333
const tooltipRef = useRef<HTMLDivElement>(null);
34-
const showTimeout = useRef<NodeJS.Timeout>(undefined);
34+
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
3535

3636
const handleOpenImmediate = () => {
3737
if (triggerRef.current == null || tooltipRef.current == null) return;

0 commit comments

Comments
 (0)