Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/features/workspaces/components/WorkspaceContextBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import WorkspaceMobileBreadcrumbOverflow from "#/features/workspaces/components/
import { WorkspaceSearchDialog } from "#/features/workspaces/components/WorkspaceSearchDialog";
import WorkspaceSettingsDialog from "#/features/workspaces/components/WorkspaceSettingsDialog";
import { WorkspaceShareDialog } from "#/features/workspaces/components/WorkspaceShareDialog";
import { WorkspaceVersionHistoryDialog } from "#/features/workspaces/components/WorkspaceVersionHistoryDialog";
import { WorkspaceToolbarGroup } from "#/features/workspaces/components/WorkspaceToolbar";
import {
renderWorkspaceMenuActions,
Expand Down Expand Up @@ -74,6 +75,7 @@ export default function WorkspaceContextBar({
const [searchOpen, setSearchOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const breadcrumbs = getWorkspaceBreadcrumbItems(activeItem, itemsById);
const mobileOverflowBreadcrumbs = breadcrumbs.slice(0, -1);
const createParentId = getWorkspaceBrowseParentId(activeItem);
Expand Down Expand Up @@ -117,6 +119,7 @@ export default function WorkspaceContextBar({
) : (
<WorkspaceRootActionsMenu
capabilities={capabilities}
onOpenHistory={() => setHistoryOpen(true)}
onOpenSettings={() => setSettingsOpen(true)}
onOpenShare={() => setShareOpen(true)}
trigger={
Expand Down Expand Up @@ -215,17 +218,24 @@ export default function WorkspaceContextBar({
workspaceId={workspace.id}
workspaceName={workspace.name}
/>
<WorkspaceVersionHistoryDialog
open={historyOpen}
onOpenChange={setHistoryOpen}
workspaceId={workspace.id}
/>
</>
);
}

function WorkspaceRootActionsMenu({
capabilities,
onOpenHistory,
onOpenSettings,
onOpenShare,
trigger,
}: {
capabilities: ReturnType<typeof useWorkspaceMutationAccess>["capabilities"];
onOpenHistory: () => void;
onOpenSettings: () => void;
onOpenShare: () => void;
trigger: ReactElement;
Expand All @@ -237,6 +247,7 @@ function WorkspaceRootActionsMenu({
{renderWorkspaceMenuActions(
getWorkspaceRootMenuActions({
canOpenSettings: capabilities.canMutateContent,
onOpenHistory,
onOpenSettings,
onOpenShare,
}),
Expand All @@ -249,6 +260,7 @@ function WorkspaceRootActionsMenu({

function getWorkspaceRootMenuActions(input: {
canOpenSettings: boolean;
onOpenHistory: () => void;
onOpenSettings: () => void;
onOpenShare: () => void;
}): WorkspaceMenuAction[] {
Expand All @@ -265,8 +277,7 @@ function getWorkspaceRootMenuActions(input: {
id: "version-history",
label: "Version history",
leading: <Clock3 className="size-4" />,
trailing: "Soon",
disabled: true,
onSelect: input.onOpenHistory,
},
{
kind: "item",
Expand Down
217 changes: 217 additions & 0 deletions src/features/workspaces/components/WorkspaceVersionHistoryDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import {
CircleDot,
FolderInput,
Loader2,
Palette,
PenLine,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { type ComponentType, useMemo } from "react";

import { Button } from "#/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "#/components/ui/dialog";
import { getWorkspaceMembersQueryOptions } from "#/features/workspaces/components/workspace-share-queries";
import { formatWorkspaceRecency } from "#/features/workspaces/model/display";
import {
mapWorkspaceHistoryEvents,
type WorkspaceHistoryEntry,
type WorkspaceHistoryEntryKind,
} from "#/features/workspaces/model/workspace-history";
import { getWorkspaceHistoryPageFn } from "#/features/workspaces/server/functions";

const historyKindIcons: Record<WorkspaceHistoryEntryKind, ComponentType<{ className?: string }>> = {
created: Plus,
renamed: PenLine,
moved: FolderInput,
color: Palette,
edited: Pencil,
deleted: Trash2,
change: CircleDot,
};

interface WorkspaceVersionHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
}

export function WorkspaceVersionHistoryDialog({
open,
onOpenChange,
workspaceId,
}: WorkspaceVersionHistoryDialogProps) {
const historyQuery = useWorkspaceHistoryQuery(workspaceId, open);
const membersQuery = useQuery({
...getWorkspaceMembersQueryOptions(workspaceId),
enabled: open,
});
const members = membersQuery.data;
const actorNamesById = useMemo(
() => (members ? new Map(members.map((member) => [member.userId, member.name])) : null),
[members],
);
const historyPages = historyQuery.data?.pages;
const entries = useMemo(
() => mapWorkspaceHistoryEvents((historyPages ?? []).flatMap((page) => page.events)),
[historyPages],
);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(36rem,85vh)] flex-col gap-4 sm:max-w-lg">
<DialogHeader>
<DialogTitle>Version history</DialogTitle>
<DialogDescription>
Every change in this workspace, newest first. History is inspection-only for now —
restoring earlier versions isn't supported yet.
</DialogDescription>
</DialogHeader>

<div className="min-h-0 flex-1 overflow-y-auto rounded-md border">
<WorkspaceVersionHistoryList
entries={entries}
getActorName={(actorUserId) => resolveActorName(actorUserId, actorNamesById)}
historyQuery={historyQuery}
/>
</div>
</DialogContent>
</Dialog>
);
}

function useWorkspaceHistoryQuery(workspaceId: string, open: boolean) {
return useInfiniteQuery({
queryKey: ["workspace-history", workspaceId],
queryFn: ({ pageParam }) =>
getWorkspaceHistoryPageFn({
data: {
workspaceId,
beforeRevision: pageParam,
},
}),
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => lastPage.nextBeforeRevision ?? undefined,
enabled: open,
});
}

function resolveActorName(actorUserId: string | null, actorNamesById: Map<string, string> | null) {
if (actorUserId === null) {
return "Someone";
}

// Until the member list has loaded, stay neutral instead of wrongly
// labeling current members as former ones.
if (!actorNamesById) {
return "Someone";
}

return actorNamesById.get(actorUserId) ?? "Former member";
}

function WorkspaceVersionHistoryList({
entries,
getActorName,
historyQuery,
}: {
entries: WorkspaceHistoryEntry[];
getActorName: (actorUserId: string | null) => string;
historyQuery: ReturnType<typeof useWorkspaceHistoryQuery>;
}) {
if (historyQuery.isPending) {
return (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden={true} />
Loading history…
</div>
);
}

if (historyQuery.isError) {
return (
<div className="flex flex-col items-center gap-3 p-6 text-center text-sm text-muted-foreground">
Could not load version history.
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void historyQuery.refetch()}
>
Try again
</Button>
</div>
);
}

if (entries.length === 0) {
return (
<div className="p-6 text-center text-sm text-muted-foreground">
No changes yet. Edits to this workspace will show up here.
</div>
);
}

return (
<ul className="divide-y">
{entries.map((entry) => (
<WorkspaceVersionHistoryRow
key={entry.id}
actorName={getActorName(entry.actorUserId)}
entry={entry}
/>
))}
{historyQuery.hasNextPage ? (
<li className="flex justify-center p-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={historyQuery.isFetchingNextPage}
onClick={() => void historyQuery.fetchNextPage()}
>
{historyQuery.isFetchingNextPage ? (
<Loader2 className="size-4 animate-spin" aria-hidden={true} />
) : null}
{historyQuery.isFetchingNextPage ? "Loading…" : "Load more"}
</Button>
</li>
) : null}
</ul>
);
}

function WorkspaceVersionHistoryRow({
actorName,
entry,
}: {
actorName: string;
entry: WorkspaceHistoryEntry;
}) {
const KindIcon = historyKindIcons[entry.kind];

return (
<li className="flex items-start gap-3 px-4 py-3 text-sm">
<KindIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden={true} />
<div className="min-w-0 flex-1">
<p className="break-words">
<span className="font-medium">{actorName}</span> {entry.summary}
</p>
</div>
<time
dateTime={entry.createdAt}
className="shrink-0 whitespace-nowrap text-xs text-muted-foreground"
>
{formatWorkspaceRecency(entry.createdAt)}
</time>
</li>
);
}
24 changes: 24 additions & 0 deletions src/features/workspaces/kernel/workspace-kernel-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
CreateWorkspaceKernelFileFromUploadArgs,
CreateWorkspaceKernelRelationArgs,
DeleteWorkspaceKernelItemsResult,
ListWorkspaceKernelHistoryArgs,
ListWorkspaceKernelItemRelationsArgs,
MoveWorkspaceKernelItemsResult,
ReadWorkspaceKernelFilePreviewResult,
Expand All @@ -30,6 +31,7 @@ import type {
WorkspaceKernelNameConflictPolicy,
} from "#/features/workspaces/kernel/workspace-kernel-types";
import type { WorkspaceFileAssetKind } from "#/features/workspaces/model/workspace-file";
import type { WorkspaceHistoryPage } from "#/features/workspaces/model/workspace-history";
import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages";
import {
assertCanMutateWorkspace,
Expand Down Expand Up @@ -123,6 +125,7 @@ export interface WorkspaceKernelClient {
actorUserId?: string | null;
clientMutationId?: string | null;
}): Promise<WorkspaceCommandResult<WorkspaceItemSummary>>;
listEventsPage(input: ListWorkspaceKernelHistoryArgs): Promise<WorkspaceHistoryPage>;
purgeForDeletion(): Promise<void>;
}

Expand Down Expand Up @@ -182,6 +185,27 @@ export async function getWorkspaceKernelPage(input: {
}
}

export async function getWorkspaceKernelHistoryPage(input: {
workspaceId: string;
userId: string;
beforeRevision?: number;
limit?: number;
}): Promise<WorkspaceHistoryPage> {
const dbContext = await createDbContext();

try {
await assertCanReadWorkspace(dbContext.db, input);
const kernel = await getWorkspaceKernel(input.workspaceId);

return await kernel.listEventsPage({
beforeRevision: input.beforeRevision,
limit: input.limit,
});
} finally {
await dbContext.dispose();
}
}

export async function listWorkspaceKernelItems({
workspaceId,
userId,
Expand Down
Loading
Loading