@saadnvd1/agent-os 0.3.770 → 0.3.777
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/live-shares/[id]/route.ts +72 -0
- package/app/api/live-shares/route.test.ts +45 -0
- package/app/api/live-shares/route.ts +40 -0
- package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/shares/preview/route.ts +6 -1
- package/app/api/shares/route.test.ts +51 -0
- package/app/page.tsx +6 -0
- package/components/Artifacts/ArtifactActions.tsx +27 -3
- package/components/Artifacts/ArtifactPreview.tsx +27 -12
- package/components/Artifacts/Compare/DiffView.tsx +116 -0
- package/components/Artifacts/Compare/SideBySide.tsx +60 -0
- package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
- package/components/Artifacts/Compare/index.tsx +191 -0
- package/components/Chat/ChatPanel.tsx +6 -0
- package/components/Chat/EventRow.tsx +6 -0
- package/components/Chat/Messages.tsx +6 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/CommandPalette/CommandPalette.tsx +5 -4
- package/components/CommandPalette/CommandsBody.tsx +97 -131
- package/components/CommandPalette/PaletteRows.tsx +79 -0
- package/components/CommandPalette/PaletteTabs.tsx +51 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
- package/components/CommandPalette/useAppCommands.ts +14 -0
- package/components/CommandPalette/useConversationRows.ts +55 -0
- package/components/Live/GuestPromptCard.tsx +64 -0
- package/components/Live/InviteDialog.tsx +225 -0
- package/components/Live/LiveChip.tsx +109 -0
- package/components/Live/LivePanel.tsx +81 -0
- package/components/Live/LobbyCard.tsx +64 -0
- package/components/Pane/MobileTabBar.tsx +8 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/SessionList/useSessionSwitching.ts +2 -2
- package/components/Share/MessageList.tsx +87 -0
- package/components/Share/RangePicker.tsx +59 -0
- package/components/Share/ShareDialog.tsx +113 -52
- package/components/Shares/SharedLinksPanel.tsx +51 -0
- package/data/live/index.ts +2 -0
- package/data/live/keys.ts +4 -0
- package/data/live/queries.ts +81 -0
- package/data/session-artifacts/keys.ts +2 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/data/shares/queries.ts +11 -0
- package/hooks/usePaletteCommands.ts +4 -1
- package/lib/artifacts/actions.ts +11 -1
- package/lib/artifacts/compare/compareVersions.ts +66 -0
- package/lib/artifacts/compare/diff.ts +178 -0
- package/lib/artifacts/compare/extractText.ts +64 -0
- package/lib/artifacts/compare/index.ts +11 -0
- package/lib/artifacts/compare/types.ts +44 -0
- package/lib/artifacts/match.ts +64 -0
- package/lib/artifacts/store.ts +39 -10
- package/lib/artifacts/tools.ts +9 -4
- package/lib/artifacts/versions.ts +2 -1
- package/lib/chat/events.ts +4 -1
- package/lib/chat/registry.ts +10 -0
- package/lib/connect/control-api.ts +44 -0
- package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
- package/lib/live/clean.ts +54 -0
- package/lib/live/frames.ts +132 -0
- package/lib/live/inbox.ts +225 -0
- package/lib/live/publisher.ts +84 -0
- package/lib/live/ring.ts +61 -0
- package/lib/live/runtime.ts +51 -0
- package/lib/live/service.ts +318 -0
- package/lib/live/socket.ts +293 -0
- package/lib/live/store.ts +149 -0
- package/lib/live/types.ts +39 -0
- package/lib/palette/registry.ts +71 -17
- package/lib/palette/sections.ts +143 -0
- package/lib/palette/tabs.ts +21 -0
- package/lib/palette/usage.ts +81 -0
- package/lib/security/demo.ts +3 -0
- package/lib/share/connect.ts +24 -0
- package/lib/share/markdown.ts +3 -0
- package/lib/share/range.ts +189 -0
- package/lib/share/redact.ts +23 -1
- package/lib/share/render.tsx +3 -0
- package/lib/share/secret-patterns.ts +77 -0
- package/lib/share/service.ts +64 -19
- package/lib/share/snapshot.ts +39 -2
- package/lib/share/styles.ts +2 -0
- package/lib/share/view.ts +11 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/liveUi.ts +17 -0
- package/stores/palette.ts +13 -11
- package/stores/shareUi.ts +6 -1
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
useSharesQuery,
|
|
10
10
|
type SharedLinkRow,
|
|
11
11
|
} from "@/data/shares";
|
|
12
|
+
import { useAllLiveShares, useEndLive, type LiveView } from "@/data/live";
|
|
12
13
|
import { cn } from "@/lib/utils";
|
|
13
14
|
import { SessionAnchor } from "@/components/Chat/SessionAnchor";
|
|
14
15
|
|
|
@@ -108,11 +109,61 @@ function Row({ share }: { share: SharedLinkRow }) {
|
|
|
108
109
|
);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
function LiveRow({ share }: { share: LiveView }) {
|
|
113
|
+
const end = useEndLive(share.id);
|
|
114
|
+
const watching = share.guests.filter((g) => g.status === "admitted").length;
|
|
115
|
+
return (
|
|
116
|
+
<li className="bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5 sm:flex-row sm:items-center">
|
|
117
|
+
<div className="min-w-0 flex-1">
|
|
118
|
+
<div className="truncate text-sm font-medium">
|
|
119
|
+
<SessionAnchor
|
|
120
|
+
sessionId={share.sessionId}
|
|
121
|
+
className="hover:underline"
|
|
122
|
+
>
|
|
123
|
+
{share.title}
|
|
124
|
+
</SessionAnchor>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
|
|
127
|
+
<span>Live</span>
|
|
128
|
+
<span>{watching} joined</span>
|
|
129
|
+
<span>
|
|
130
|
+
{share.endsWithSession
|
|
131
|
+
? "Until the session ends"
|
|
132
|
+
: share.expiresAt
|
|
133
|
+
? `Ends ${new Date(share.expiresAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
|
|
134
|
+
: ""}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<Button
|
|
139
|
+
variant="outline"
|
|
140
|
+
className="text-destructive min-h-11 shrink-0 sm:min-h-8"
|
|
141
|
+
disabled={end.isPending}
|
|
142
|
+
onClick={() =>
|
|
143
|
+
end.mutate(undefined, {
|
|
144
|
+
onError: (err) => toast.error((err as Error).message),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
End access
|
|
149
|
+
</Button>
|
|
150
|
+
</li>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
111
154
|
// Every snapshot link made from this machine, with views from Connect.
|
|
112
155
|
export function SharedLinksPanel() {
|
|
113
156
|
const { data, isLoading, error } = useSharesQuery();
|
|
157
|
+
const liveNow = useAllLiveShares().data?.shares ?? [];
|
|
114
158
|
return (
|
|
115
159
|
<div className="space-y-3">
|
|
160
|
+
{liveNow.length > 0 && (
|
|
161
|
+
<ul className="space-y-2">
|
|
162
|
+
{liveNow.map((s) => (
|
|
163
|
+
<LiveRow key={s.id} share={s} />
|
|
164
|
+
))}
|
|
165
|
+
</ul>
|
|
166
|
+
)}
|
|
116
167
|
<p className="text-muted-foreground text-sm">
|
|
117
168
|
Read-only snapshots you shared from a chat or a page. Revoking one
|
|
118
169
|
deletes it from Connect straight away.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
|
|
3
|
+
import { liveKeys } from "./keys";
|
|
4
|
+
|
|
5
|
+
export type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
|
|
6
|
+
|
|
7
|
+
async function json<T>(res: Response): Promise<T> {
|
|
8
|
+
const data = await res.json().catch(() => ({}));
|
|
9
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
10
|
+
return data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const send = (url: string, method: string, body?: unknown) =>
|
|
14
|
+
fetch(url, {
|
|
15
|
+
method,
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The session's live link, who is waiting and who is watching. Asked every
|
|
22
|
+
* couple of seconds while a link is live, and once a minute when not, so a
|
|
23
|
+
* request or a suggestion shows up on the host's screen quickly.
|
|
24
|
+
*/
|
|
25
|
+
export function useLiveShare(sessionId: string | undefined, enabled = true) {
|
|
26
|
+
return useQuery({
|
|
27
|
+
queryKey: liveKeys.session(sessionId ?? ""),
|
|
28
|
+
enabled: enabled && !!sessionId,
|
|
29
|
+
queryFn: async (): Promise<LiveResponse> =>
|
|
30
|
+
json(await fetch(`/api/live-shares?sessionId=${sessionId}`)),
|
|
31
|
+
select: (r) => ({
|
|
32
|
+
linked: r.linked,
|
|
33
|
+
share: r.shares[0] as LiveView | undefined,
|
|
34
|
+
}),
|
|
35
|
+
refetchInterval: (q) => (q.state.data?.shares.length ? 2000 : 60_000),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useCreateLive() {
|
|
40
|
+
const qc = useQueryClient();
|
|
41
|
+
return useMutation({
|
|
42
|
+
mutationFn: async (req: { sessionId: string; expiresIn: LiveExpiry }) =>
|
|
43
|
+
json<LiveView>(await send("/api/live-shares", "POST", req)),
|
|
44
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LiveAction =
|
|
49
|
+
| { action: "admit" | "deny" | "kick"; guestId: string }
|
|
50
|
+
| { action: "send"; suggestionId: string; text?: string }
|
|
51
|
+
| { action: "dismiss"; suggestionId: string };
|
|
52
|
+
|
|
53
|
+
export function useLiveAction(shareId: string) {
|
|
54
|
+
const qc = useQueryClient();
|
|
55
|
+
return useMutation({
|
|
56
|
+
mutationFn: async (req: LiveAction) =>
|
|
57
|
+
json<{ ok: true }>(
|
|
58
|
+
await send(`/api/live-shares/${shareId}`, "POST", req)
|
|
59
|
+
),
|
|
60
|
+
onSettled: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useEndLive(shareId: string) {
|
|
65
|
+
const qc = useQueryClient();
|
|
66
|
+
return useMutation({
|
|
67
|
+
mutationFn: async () =>
|
|
68
|
+
json<{ ok: true }>(await send(`/api/live-shares/${shareId}`, "DELETE")),
|
|
69
|
+
onSettled: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every live link this machine holds, for the Shared links page. */
|
|
74
|
+
export function useAllLiveShares() {
|
|
75
|
+
return useQuery({
|
|
76
|
+
queryKey: [...liveKeys.all, "all"] as const,
|
|
77
|
+
queryFn: async (): Promise<LiveResponse> =>
|
|
78
|
+
json(await fetch("/api/live-shares")),
|
|
79
|
+
refetchInterval: 5000,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -7,4 +7,6 @@ export const sessionArtifactKeys = {
|
|
|
7
7
|
// A page's versions, by any version's id; pageId keys the invalidation.
|
|
8
8
|
versions: (pageId: string) =>
|
|
9
9
|
[...sessionArtifactKeys.all, "versions", pageId] as const,
|
|
10
|
+
compare: (sessionId: string, from: string, to: string, mode: string) =>
|
|
11
|
+
[...sessionArtifactKeys.all, "compare", sessionId, from, to, mode] as const,
|
|
10
12
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import type { SessionArtifact } from "@/lib/session-artifacts/types";
|
|
3
3
|
import type { PageVersions } from "@/lib/artifacts/actions";
|
|
4
|
+
import type { CompareMode, CompareResult } from "@/lib/artifacts/compare/types";
|
|
4
5
|
import { sessionArtifactKeys } from "./keys";
|
|
5
6
|
|
|
6
7
|
export type { SessionArtifact } from "@/lib/session-artifacts/types";
|
|
@@ -76,3 +77,27 @@ export function useArtifactVersions(
|
|
|
76
77
|
staleTime: 30_000,
|
|
77
78
|
});
|
|
78
79
|
}
|
|
80
|
+
|
|
81
|
+
/** What changed between two versions of a page. */
|
|
82
|
+
export function useArtifactCompare(
|
|
83
|
+
sessionId: string,
|
|
84
|
+
fromId: string,
|
|
85
|
+
toId: string,
|
|
86
|
+
mode: CompareMode,
|
|
87
|
+
enabled = true
|
|
88
|
+
) {
|
|
89
|
+
return useQuery({
|
|
90
|
+
queryKey: sessionArtifactKeys.compare(sessionId, fromId, toId, mode),
|
|
91
|
+
queryFn: async (): Promise<Extract<CompareResult, { ok: true }>> => {
|
|
92
|
+
const res = await fetch(
|
|
93
|
+
`${artifactContentUrl(sessionId, fromId)}/compare?to=${encodeURIComponent(toId)}&mode=${mode}`
|
|
94
|
+
);
|
|
95
|
+
const data = await res.json().catch(() => ({}));
|
|
96
|
+
if (!res.ok) throw new Error(data.error || "Couldn't compare versions");
|
|
97
|
+
return data;
|
|
98
|
+
},
|
|
99
|
+
enabled: enabled && !!fromId && !!toId,
|
|
100
|
+
// Versions never change once saved.
|
|
101
|
+
staleTime: Infinity,
|
|
102
|
+
});
|
|
103
|
+
}
|
package/data/shares/queries.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type {
|
|
3
3
|
CreatedShare,
|
|
4
|
+
MessageList,
|
|
4
5
|
PreviewRequest,
|
|
5
6
|
SharePreview,
|
|
6
7
|
SharesResponse,
|
|
@@ -40,6 +41,16 @@ export function useSharePreview() {
|
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Every message of a chat, for picking the part to share. */
|
|
45
|
+
export function useShareMessages() {
|
|
46
|
+
return useMutation({
|
|
47
|
+
mutationFn: async (req: PreviewRequest) =>
|
|
48
|
+
json<MessageList>(
|
|
49
|
+
await post("/api/shares/preview", { ...req, listOnly: true })
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/** The share as a PDF, printed by the server. */
|
|
44
55
|
export function useSharePdf() {
|
|
45
56
|
return useMutation({
|
|
@@ -17,7 +17,10 @@ export function usePaletteCommands(
|
|
|
17
17
|
});
|
|
18
18
|
const shape = commands
|
|
19
19
|
? commands
|
|
20
|
-
.map(
|
|
20
|
+
.map(
|
|
21
|
+
(c) =>
|
|
22
|
+
`${c.id}\u0000${c.title}\u0000${c.hint ?? ""}\u0000${c.status ?? ""}`
|
|
23
|
+
)
|
|
21
24
|
.join("\u0001")
|
|
22
25
|
: null;
|
|
23
26
|
useEffect(() => {
|
package/lib/artifacts/actions.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type ArtifactActionId =
|
|
|
7
7
|
| "share"
|
|
8
8
|
| "open"
|
|
9
9
|
| "download"
|
|
10
|
+
| "compare"
|
|
10
11
|
| "open-pr"
|
|
11
12
|
| "copy-path"
|
|
12
13
|
| "open-in-files";
|
|
@@ -36,16 +37,25 @@ export interface ArtifactVersion {
|
|
|
36
37
|
|
|
37
38
|
export interface PageVersions {
|
|
38
39
|
pageId: string;
|
|
40
|
+
// The page's name: its latest version's title.
|
|
41
|
+
title: string;
|
|
39
42
|
latestId: string;
|
|
40
43
|
versions: ArtifactVersion[];
|
|
41
44
|
actions: ArtifactAction[];
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
// versions: how many versions the page has; Compare needs two.
|
|
48
|
+
export function pageActions(ctx: {
|
|
49
|
+
pdf: boolean;
|
|
50
|
+
versions?: number;
|
|
51
|
+
}): ArtifactAction[] {
|
|
45
52
|
return [
|
|
46
53
|
{ id: "fullscreen", label: "Full screen" },
|
|
47
54
|
{ id: "share", label: "Share" },
|
|
48
55
|
{ id: "open", label: "Open" },
|
|
56
|
+
...((ctx.versions ?? 1) > 1
|
|
57
|
+
? [{ id: "compare" as const, label: "Compare" }]
|
|
58
|
+
: []),
|
|
49
59
|
{
|
|
50
60
|
id: "download",
|
|
51
61
|
label: "Download",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getArtifact, readArtifactHtml } from "../store";
|
|
2
|
+
import { toVersion } from "../versions";
|
|
3
|
+
import { diffLines } from "./diff";
|
|
4
|
+
import { extractText } from "./extractText";
|
|
5
|
+
import type { CompareMode, CompareResult } from "./types";
|
|
6
|
+
|
|
7
|
+
// Per version; bigger pages are only shown side by side.
|
|
8
|
+
export const MAX_COMPARE_BYTES = 512 * 1024;
|
|
9
|
+
|
|
10
|
+
const NOT_FOUND = {
|
|
11
|
+
ok: false,
|
|
12
|
+
status: 404,
|
|
13
|
+
error: "Version not found",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
// Two versions of one page in one session, as a diff. Both must belong to
|
|
17
|
+
// the session asked about and to the same page: an id from anywhere else
|
|
18
|
+
// reads as not found, so ids can't be probed across sessions.
|
|
19
|
+
export async function compareVersions(
|
|
20
|
+
sessionId: string,
|
|
21
|
+
fromId: string,
|
|
22
|
+
toId: string,
|
|
23
|
+
mode: CompareMode
|
|
24
|
+
): Promise<CompareResult> {
|
|
25
|
+
const from = getArtifact(fromId);
|
|
26
|
+
const to = getArtifact(toId);
|
|
27
|
+
if (!from || !to) return NOT_FOUND;
|
|
28
|
+
if (from.session_id !== sessionId || to.session_id !== sessionId)
|
|
29
|
+
return NOT_FOUND;
|
|
30
|
+
if (from.page_id !== to.page_id)
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
status: 400,
|
|
34
|
+
error: "Those versions belong to different pages",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const head = {
|
|
38
|
+
ok: true,
|
|
39
|
+
mode,
|
|
40
|
+
from: toVersion(from),
|
|
41
|
+
to: toVersion(to),
|
|
42
|
+
} as const;
|
|
43
|
+
const tooBig = (a: { bytes: number | null }) =>
|
|
44
|
+
(a.bytes ?? 0) > MAX_COMPARE_BYTES;
|
|
45
|
+
const tooLarge = (reason: string): CompareResult => ({
|
|
46
|
+
...head,
|
|
47
|
+
tooLarge: true,
|
|
48
|
+
reason,
|
|
49
|
+
});
|
|
50
|
+
if (tooBig(from) || tooBig(to)) return tooLarge("too large to diff");
|
|
51
|
+
|
|
52
|
+
const [a, b] = await Promise.all([
|
|
53
|
+
readArtifactHtml(from),
|
|
54
|
+
readArtifactHtml(to),
|
|
55
|
+
]);
|
|
56
|
+
if (a === null || b === null) return NOT_FOUND;
|
|
57
|
+
if (a.length > MAX_COMPARE_BYTES || b.length > MAX_COMPARE_BYTES)
|
|
58
|
+
return tooLarge("too large to diff");
|
|
59
|
+
|
|
60
|
+
const result =
|
|
61
|
+
mode === "source"
|
|
62
|
+
? diffLines(a, b)
|
|
63
|
+
: diffLines(extractText(a), extractText(b));
|
|
64
|
+
if (!result.ok) return tooLarge("too many changes to diff");
|
|
65
|
+
return { ...head, summary: result.summary, lines: result.lines };
|
|
66
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// A line diff (Myers' O(ND)) with a word diff inside the changed lines that
|
|
2
|
+
// pair up. Bounded: past the limits it gives up instead of burning the CPU.
|
|
3
|
+
|
|
4
|
+
import type { DiffLine, DiffSummary, WordPart } from "./types";
|
|
5
|
+
|
|
6
|
+
export const MAX_LINES = 20_000;
|
|
7
|
+
// Edit distance past which two texts are different enough not to diff.
|
|
8
|
+
export const MAX_EDITS = 4_000;
|
|
9
|
+
const MAX_WORD_TOKENS = 600;
|
|
10
|
+
|
|
11
|
+
type Op = { t: "eq" | "add" | "del"; i: number; j: number };
|
|
12
|
+
|
|
13
|
+
// Edit script from a to b, or null when it needs more than maxEdits.
|
|
14
|
+
function myers(a: string[], b: string[], maxEdits: number): Op[] | null {
|
|
15
|
+
const n = a.length;
|
|
16
|
+
const m = b.length;
|
|
17
|
+
const max = Math.min(n + m, maxEdits);
|
|
18
|
+
const offset = max + 1;
|
|
19
|
+
const v = new Int32Array(2 * max + 3);
|
|
20
|
+
const trace: Int32Array[] = [];
|
|
21
|
+
let found = -1;
|
|
22
|
+
for (let d = 0; d <= max && found < 0; d++) {
|
|
23
|
+
trace.push(v.slice());
|
|
24
|
+
for (let k = -d; k <= d; k += 2) {
|
|
25
|
+
let x =
|
|
26
|
+
k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
|
|
27
|
+
? v[offset + k + 1]
|
|
28
|
+
: v[offset + k - 1] + 1;
|
|
29
|
+
let y = x - k;
|
|
30
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
31
|
+
x++;
|
|
32
|
+
y++;
|
|
33
|
+
}
|
|
34
|
+
v[offset + k] = x;
|
|
35
|
+
if (x >= n && y >= m) {
|
|
36
|
+
found = d;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (found < 0) return null;
|
|
42
|
+
const ops: Op[] = [];
|
|
43
|
+
let x = n;
|
|
44
|
+
let y = m;
|
|
45
|
+
for (let d = found; d > 0; d--) {
|
|
46
|
+
const prev = trace[d];
|
|
47
|
+
const k = x - y;
|
|
48
|
+
const down =
|
|
49
|
+
k === -d || (k !== d && prev[offset + k - 1] < prev[offset + k + 1]);
|
|
50
|
+
const pk = down ? k + 1 : k - 1;
|
|
51
|
+
const px = prev[offset + pk];
|
|
52
|
+
const py = px - pk;
|
|
53
|
+
while (x > px && y > py) {
|
|
54
|
+
x--;
|
|
55
|
+
y--;
|
|
56
|
+
ops.push({ t: "eq", i: x, j: y });
|
|
57
|
+
}
|
|
58
|
+
if (down) {
|
|
59
|
+
y--;
|
|
60
|
+
ops.push({ t: "add", i: x, j: y });
|
|
61
|
+
} else {
|
|
62
|
+
x--;
|
|
63
|
+
ops.push({ t: "del", i: x, j: y });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
while (x > 0 && y > 0) {
|
|
67
|
+
x--;
|
|
68
|
+
y--;
|
|
69
|
+
ops.push({ t: "eq", i: x, j: y });
|
|
70
|
+
}
|
|
71
|
+
return ops.reverse();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tokens = (s: string) =>
|
|
75
|
+
s.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? [];
|
|
76
|
+
|
|
77
|
+
// The words of two paired lines: what each side kept and what it changed.
|
|
78
|
+
function wordParts(
|
|
79
|
+
before: string,
|
|
80
|
+
after: string
|
|
81
|
+
): { del: WordPart[]; add: WordPart[] } | null {
|
|
82
|
+
const a = tokens(before);
|
|
83
|
+
const b = tokens(after);
|
|
84
|
+
if (a.length > MAX_WORD_TOKENS || b.length > MAX_WORD_TOKENS) return null;
|
|
85
|
+
const ops = myers(a, b, MAX_WORD_TOKENS);
|
|
86
|
+
if (!ops) return null;
|
|
87
|
+
const del: WordPart[] = [];
|
|
88
|
+
const add: WordPart[] = [];
|
|
89
|
+
const push = (list: WordPart[], t: WordPart["t"], s: string) => {
|
|
90
|
+
const last = list[list.length - 1];
|
|
91
|
+
if (last && last.t === t) last.s += s;
|
|
92
|
+
else list.push({ t, s });
|
|
93
|
+
};
|
|
94
|
+
for (const op of ops) {
|
|
95
|
+
if (op.t === "eq") {
|
|
96
|
+
push(del, "eq", a[op.i]);
|
|
97
|
+
push(add, "eq", b[op.j]);
|
|
98
|
+
} else if (op.t === "del") push(del, "del", a[op.i]);
|
|
99
|
+
else push(add, "add", b[op.j]);
|
|
100
|
+
}
|
|
101
|
+
return { del, add };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type DiffResult =
|
|
105
|
+
{ ok: true; lines: DiffLine[]; summary: DiffSummary } | { ok: false };
|
|
106
|
+
|
|
107
|
+
export function diffLines(before: string, after: string): DiffResult {
|
|
108
|
+
const a = before === "" ? [] : before.split("\n");
|
|
109
|
+
const b = after === "" ? [] : after.split("\n");
|
|
110
|
+
if (a.length > MAX_LINES || b.length > MAX_LINES) return { ok: false };
|
|
111
|
+
const ops = myers(a, b, MAX_EDITS);
|
|
112
|
+
if (!ops) return { ok: false };
|
|
113
|
+
|
|
114
|
+
const lines: DiffLine[] = [];
|
|
115
|
+
let added = 0;
|
|
116
|
+
let removed = 0;
|
|
117
|
+
for (let p = 0; p < ops.length;) {
|
|
118
|
+
if (ops[p].t === "eq") {
|
|
119
|
+
lines.push({ t: "eq", s: a[ops[p].i] });
|
|
120
|
+
p++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// A run of changes: removals first, then additions, paired in order.
|
|
124
|
+
const dels: string[] = [];
|
|
125
|
+
const adds: string[] = [];
|
|
126
|
+
while (p < ops.length && ops[p].t !== "eq") {
|
|
127
|
+
if (ops[p].t === "del") dels.push(a[ops[p].i]);
|
|
128
|
+
else adds.push(b[ops[p].j]);
|
|
129
|
+
p++;
|
|
130
|
+
}
|
|
131
|
+
removed += dels.length;
|
|
132
|
+
added += adds.length;
|
|
133
|
+
const delLines: DiffLine[] = dels.map((s) => ({ t: "del", s }));
|
|
134
|
+
const addLines: DiffLine[] = adds.map((s) => ({ t: "add", s }));
|
|
135
|
+
for (let q = 0; q < Math.min(dels.length, adds.length); q++) {
|
|
136
|
+
const w = wordParts(dels[q], adds[q]);
|
|
137
|
+
if (w) {
|
|
138
|
+
delLines[q].words = w.del;
|
|
139
|
+
addLines[q].words = w.add;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
lines.push(...delLines, ...addLines);
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, lines, summary: { added, removed } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function summaryLabel(s: DiffSummary): string {
|
|
148
|
+
if (!s.added && !s.removed) return "No changes";
|
|
149
|
+
return `+${s.added} −${s.removed} lines`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type Shown = DiffLine | { gap: number };
|
|
153
|
+
|
|
154
|
+
// Long unchanged stretches folded to a gap, keeping `context` lines around
|
|
155
|
+
// every change.
|
|
156
|
+
export function foldUnchanged(lines: DiffLine[], context = 3): Shown[] {
|
|
157
|
+
const keep = new Array<boolean>(lines.length).fill(false);
|
|
158
|
+
lines.forEach((l, i) => {
|
|
159
|
+
if (l.t === "eq") return;
|
|
160
|
+
for (
|
|
161
|
+
let k = Math.max(0, i - context);
|
|
162
|
+
k <= Math.min(lines.length - 1, i + context);
|
|
163
|
+
k++
|
|
164
|
+
)
|
|
165
|
+
keep[k] = true;
|
|
166
|
+
});
|
|
167
|
+
const out: Shown[] = [];
|
|
168
|
+
let gap = 0;
|
|
169
|
+
lines.forEach((l, i) => {
|
|
170
|
+
if (keep[i]) {
|
|
171
|
+
if (gap) out.push({ gap });
|
|
172
|
+
gap = 0;
|
|
173
|
+
out.push(l);
|
|
174
|
+
} else gap++;
|
|
175
|
+
});
|
|
176
|
+
if (gap) out.push({ gap });
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The text a reader sees on a page, one block per line: scripts, styles and
|
|
2
|
+
// the head gone, headings, paragraphs, list items and table cells each on
|
|
3
|
+
// their own line.
|
|
4
|
+
|
|
5
|
+
const DROPPED =
|
|
6
|
+
/<(script|style|noscript|template|head|svg|iframe)\b[\s\S]*?<\/\1\s*>/gi;
|
|
7
|
+
const COMMENTS = /<!--[\s\S]*?-->/g;
|
|
8
|
+
const BREAKING =
|
|
9
|
+
/<\/?(?:p|div|section|article|aside|header|footer|main|nav|ul|ol|li|dl|dt|dd|h[1-6]|tr|td|th|table|thead|tbody|tfoot|caption|blockquote|pre|figure|figcaption|form|fieldset|details|summary|hr|br|address|label|button|option)\b[^>]*>/gi;
|
|
10
|
+
const TAGS = /<[^>]*>/g;
|
|
11
|
+
|
|
12
|
+
const NAMED: Record<string, string> = {
|
|
13
|
+
amp: "&",
|
|
14
|
+
lt: "<",
|
|
15
|
+
gt: ">",
|
|
16
|
+
quot: '"',
|
|
17
|
+
apos: "'",
|
|
18
|
+
nbsp: " ",
|
|
19
|
+
ndash: "–",
|
|
20
|
+
mdash: "—",
|
|
21
|
+
hellip: "…",
|
|
22
|
+
lsquo: "‘",
|
|
23
|
+
rsquo: "’",
|
|
24
|
+
ldquo: "“",
|
|
25
|
+
rdquo: "”",
|
|
26
|
+
middot: "·",
|
|
27
|
+
bull: "•",
|
|
28
|
+
times: "×",
|
|
29
|
+
rarr: "→",
|
|
30
|
+
larr: "←",
|
|
31
|
+
copy: "©",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function codePoint(n: number, whole: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : whole;
|
|
37
|
+
} catch {
|
|
38
|
+
return whole;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decodeEntities(s: string): string {
|
|
43
|
+
return s.replace(
|
|
44
|
+
/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,8}));/g,
|
|
45
|
+
(whole, dec, hex, name) => {
|
|
46
|
+
if (dec) return codePoint(parseInt(dec, 10), whole);
|
|
47
|
+
if (hex) return codePoint(parseInt(hex, 16), whole);
|
|
48
|
+
return NAMED[name.toLowerCase()] ?? whole;
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractText(html: string): string {
|
|
54
|
+
const flat = html
|
|
55
|
+
.replace(COMMENTS, "")
|
|
56
|
+
.replace(DROPPED, "")
|
|
57
|
+
.replace(BREAKING, "\n")
|
|
58
|
+
.replace(TAGS, "");
|
|
59
|
+
return decodeEntities(flat)
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { compareVersions, MAX_COMPARE_BYTES } from "./compareVersions";
|
|
2
|
+
export { diffLines, foldUnchanged, summaryLabel, type Shown } from "./diff";
|
|
3
|
+
export { extractText } from "./extractText";
|
|
4
|
+
export type {
|
|
5
|
+
CompareMode,
|
|
6
|
+
CompareResult,
|
|
7
|
+
Comparison,
|
|
8
|
+
DiffLine,
|
|
9
|
+
DiffSummary,
|
|
10
|
+
WordPart,
|
|
11
|
+
} from "./types";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ArtifactVersion } from "../actions";
|
|
2
|
+
|
|
3
|
+
export type CompareMode = "changes" | "source";
|
|
4
|
+
|
|
5
|
+
// A piece of one line: kept, or (on a removed line) taken out, or (on an
|
|
6
|
+
// added line) put in.
|
|
7
|
+
export interface WordPart {
|
|
8
|
+
t: "eq" | "add" | "del";
|
|
9
|
+
s: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DiffLine {
|
|
13
|
+
t: "eq" | "add" | "del";
|
|
14
|
+
s: string;
|
|
15
|
+
// Only on a removed or added line that pairs with one on the other side.
|
|
16
|
+
words?: WordPart[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiffSummary {
|
|
20
|
+
added: number;
|
|
21
|
+
removed: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Comparison =
|
|
25
|
+
| {
|
|
26
|
+
ok: true;
|
|
27
|
+
mode: CompareMode;
|
|
28
|
+
from: ArtifactVersion;
|
|
29
|
+
to: ArtifactVersion;
|
|
30
|
+
summary: DiffSummary;
|
|
31
|
+
lines: DiffLine[];
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
ok: true;
|
|
35
|
+
mode: CompareMode;
|
|
36
|
+
from: ArtifactVersion;
|
|
37
|
+
to: ArtifactVersion;
|
|
38
|
+
// Too big to diff: the client shows the pages side by side only.
|
|
39
|
+
tooLarge: true;
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type CompareResult =
|
|
44
|
+
Comparison | { ok: false; status: 400 | 404; error: string };
|