Skip to content

Commit 01a0ac3

Browse files
authored
Pin button (#122)
* wip(patch show apply): fetch the patch data from the lsp * fix(patchResult type guard): add proper type checks for patchResult. * feat(pin open): open an unsaved file in the ide. * refactor(pin): use a stricter regexp for detecting the end of the markdown. * feat(pins): add apply button and share chunks cache with diffs. * feat(pins): allow the ide to reset the diff cache when applying / saving diffs * ui(markdown): limit pin messages to only assistant messages. * ui(pin buttons): wrap buttons above long 📍 message * chore(pins): remove todos and hide show button in web host * refactor(pin): lazily fetch chunks and show warning / error message if result doesn't work. * ui(pin): move callout to bellow the button * chore(dev max age for dev tools): decrease to 500 * refactor(pin warning): reuse diff warning callout * refactor(pin callout): add click handler and timeout props.
1 parent b3089e9 commit 01a0ac3

File tree

9 files changed

+388
-11
lines changed

9 files changed

+388
-11
lines changed

src/app/store.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import { errorSlice } from "../features/Errors/errorsSlice";
4040
import { warningSlice } from "../features/Errors/warningSlice";
4141
import { pagesSlice } from "../features/Pages/pagesSlice";
42+
import { openFilesSlice } from "../features/OpenFiles/openFilesSlice";
4243
import mergeInitialState from "redux-persist/lib/stateReconciler/autoMergeLevel2";
4344
import { listenerMiddleware } from "./middleware";
4445

@@ -78,6 +79,7 @@ const rootReducer = combineSlices(
7879
errorSlice,
7980
warningSlice,
8081
pagesSlice,
82+
openFilesSlice,
8183
);
8284

8385
const rootPersistConfig = {
@@ -104,7 +106,7 @@ export function setUpStore(preloadedState?: Partial<RootState>) {
104106
reducer: persistedReducer,
105107
preloadedState: initialState,
106108
devTools: {
107-
maxAge: 1000,
109+
maxAge: 500,
108110
},
109111
middleware: (getDefaultMiddleware) => {
110112
const production = import.meta.env.MODE === "production";

src/components/ChatContent/AssistantInput.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const AssistantInput: React.FC<ChatInputProps> = ({
5555
onNewFileClick={newFile}
5656
onPasteClick={diffPasteBack}
5757
canPaste={activeFile.can_paste}
58+
canHavePins={true}
5859
>
5960
{message}
6061
</Markdown>

src/components/Markdown/Markdown.tsx

+188-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo } from "react";
1+
import React, { Key, useCallback, useMemo, useState } from "react";
22
import ReactMarkdown, { Components } from "react-markdown";
33
import remarkBreaks from "remark-breaks";
44
import classNames from "classnames";
@@ -18,10 +18,22 @@ import {
1818
Link,
1919
Quote,
2020
Strong,
21+
Button,
22+
Flex,
23+
Box,
2124
} from "@radix-ui/themes";
2225
import rehypeKatex from "rehype-katex";
2326
import remarkMath from "remark-math";
2427
import "katex/dist/katex.min.css";
28+
import { diffApi } from "../../services/refact";
29+
import {
30+
useConfig,
31+
useDiffApplyMutation,
32+
useEventsBusForIDE,
33+
} from "../../hooks";
34+
import { selectOpenFiles } from "../../features/OpenFiles/openFilesSlice";
35+
import { useSelector } from "react-redux";
36+
import { ErrorCallout, DiffWarningCallout } from "../Callout";
2537

2638
export type MarkdownProps = Pick<
2739
React.ComponentProps<typeof ReactMarkdown>,
@@ -31,15 +43,184 @@ export type MarkdownProps = Pick<
3143
Pick<
3244
MarkdownCodeBlockProps,
3345
"startingLineNumber" | "showLineNumbers" | "useInlineStyles" | "style"
34-
>;
46+
> & { canHavePins?: boolean };
47+
48+
const MaybePinButton: React.FC<{
49+
key?: Key | null;
50+
children?: React.ReactNode;
51+
getMarkdown: (pin: string) => string | undefined;
52+
}> = ({ children, getMarkdown }) => {
53+
const { host } = useConfig();
54+
55+
const { diffPreview } = useEventsBusForIDE();
56+
const { onSubmit, result: _result } = useDiffApplyMutation();
57+
const openFiles = useSelector(selectOpenFiles);
58+
const isPin = typeof children === "string" && children.startsWith("📍");
59+
const markdown = getMarkdown(String(children));
60+
61+
const [errorMessage, setErrorMessage] = useState<{
62+
type: "warning" | "error";
63+
text: string;
64+
} | null>(null);
65+
66+
const [getPatch, _patchResult] =
67+
diffApi.useLazyPatchSingleFileFromTicketQuery();
68+
69+
const handleShow = useCallback(() => {
70+
if (typeof children !== "string") return;
71+
if (!markdown) return;
72+
73+
getPatch({ pin: children, markdown })
74+
.unwrap()
75+
.then((patch) => {
76+
if (patch.chunks.length === 0) {
77+
throw new Error("No Chunks to show");
78+
}
79+
diffPreview(patch);
80+
})
81+
.catch(() => {
82+
setErrorMessage({ type: "warning", text: "No patch to show" });
83+
});
84+
}, [children, diffPreview, getPatch, markdown]);
85+
86+
const handleApply = useCallback(() => {
87+
if (typeof children !== "string") return;
88+
if (!markdown) return;
89+
getPatch({ pin: children, markdown })
90+
.unwrap()
91+
.then((patch) => {
92+
const files = patch.results.reduce<string[]>((acc, cur) => {
93+
const { file_name_add, file_name_delete, file_name_edit } = cur;
94+
if (file_name_add) acc.push(file_name_add);
95+
if (file_name_delete) acc.push(file_name_delete);
96+
if (file_name_edit) acc.push(file_name_edit);
97+
return acc;
98+
}, []);
99+
100+
if (files.length === 0) {
101+
setErrorMessage({ type: "warning", text: "No chunks to apply" });
102+
return;
103+
}
104+
105+
const fileIsOpen = files.some((file) => openFiles.includes(file));
106+
107+
if (fileIsOpen) {
108+
diffPreview(patch);
109+
} else {
110+
const chunks = patch.chunks;
111+
const toApply = chunks.map(() => true);
112+
void onSubmit({ chunks, toApply });
113+
}
114+
})
115+
.catch((error: Error) => {
116+
setErrorMessage({
117+
type: "error",
118+
text: error.message
119+
? "Failed to apply patch: " + error.message
120+
: "Failed to apply patch.",
121+
});
122+
});
123+
}, [children, diffPreview, getPatch, markdown, onSubmit, openFiles]);
124+
125+
const handleCalloutClick = useCallback(() => {
126+
setErrorMessage(null);
127+
}, []);
128+
129+
if (isPin) {
130+
return (
131+
<Box>
132+
<Flex my="2" gap="2" wrap="wrap-reverse">
133+
<Text
134+
as="p"
135+
wrap="wrap"
136+
style={{ lineBreak: "anywhere", wordBreak: "break-all" }}
137+
>
138+
{children}
139+
</Text>
140+
<Flex gap="2" justify="end" ml="auto">
141+
{host !== "web" && (
142+
<Button
143+
size="1"
144+
// loading={patchResult.isFetching}
145+
onClick={handleShow}
146+
title="Show Patch"
147+
disabled={!!errorMessage}
148+
>
149+
Open
150+
</Button>
151+
)}
152+
<Button
153+
size="1"
154+
// loading={patchResult.isFetching}
155+
onClick={handleApply}
156+
disabled={!!errorMessage}
157+
title="Apply patch"
158+
>
159+
Apply
160+
</Button>
161+
</Flex>
162+
</Flex>
163+
{errorMessage && errorMessage.type === "error" && (
164+
<ErrorCallout onClick={handleCalloutClick} timeout={3000}>
165+
{errorMessage.text}
166+
</ErrorCallout>
167+
)}
168+
{errorMessage && errorMessage.type === "warning" && (
169+
<DiffWarningCallout
170+
timeout={3000}
171+
onClick={handleCalloutClick}
172+
message={errorMessage.text}
173+
/>
174+
)}
175+
</Box>
176+
);
177+
}
178+
179+
return (
180+
<Text my="2" as="p">
181+
{children}
182+
</Text>
183+
);
184+
};
185+
186+
function processPinAndMarkdown(message?: string | null): Map<string, string> {
187+
if (!message) return new Map<string, string>();
188+
189+
const regexp = /📍[\s\S]*?\n```\n/g;
190+
191+
const results = message.match(regexp) ?? [];
192+
193+
const pinsAndMarkdown = results.map<[string, string]>((result) => {
194+
const firstNewLine = result.indexOf("\n");
195+
const pin = result.slice(0, firstNewLine);
196+
const markdown = result.slice(firstNewLine + 1);
197+
return [pin, markdown];
198+
});
199+
200+
const hashMap = new Map(pinsAndMarkdown);
201+
202+
return hashMap;
203+
}
35204

36205
const _Markdown: React.FC<MarkdownProps> = ({
37206
children,
38207
allowedElements,
39208
unwrapDisallowed,
40-
209+
canHavePins,
41210
...rest
42211
}) => {
212+
const pinsAndMarkdown = useMemo<Map<string, string>>(
213+
() => processPinAndMarkdown(children),
214+
[children],
215+
);
216+
217+
const getMarkDownForPin = useCallback(
218+
(pin: string) => {
219+
return pinsAndMarkdown.get(pin);
220+
},
221+
[pinsAndMarkdown],
222+
);
223+
43224
const components: Partial<Components> = useMemo(() => {
44225
return {
45226
ol(props) {
@@ -56,6 +237,9 @@ const _Markdown: React.FC<MarkdownProps> = ({
56237
return <MarkdownCodeBlock {...props} {...rest} />;
57238
},
58239
p({ color: _color, ref: _ref, node: _node, ...props }) {
240+
if (canHavePins) {
241+
return <MaybePinButton {...props} getMarkdown={getMarkDownForPin} />;
242+
}
59243
return <Text my="2" as="p" {...props} />;
60244
},
61245
h1({ color: _color, ref: _ref, node: _node, ...props }) {
@@ -101,7 +285,7 @@ const _Markdown: React.FC<MarkdownProps> = ({
101285
return <Em {...props} />;
102286
},
103287
};
104-
}, [rest]);
288+
}, [getMarkDownForPin, rest, canHavePins]);
105289
return (
106290
<ReactMarkdown
107291
className={styles.markdown}

src/events/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@ import type { TipOfTheDayState } from "../features/TipOfTheDay";
1313
import type { PageSliceState } from "../features/Pages/pagesSlice";
1414
import type { TourState } from "../features/Tour";
1515
import type { FIMDebugState } from "../hooks";
16+
1617
// import { rootReducer } from "../app/store";
1718
export { updateConfig, type Config } from "../features/Config/configSlice";
1819
export { type FileInfo, setFileInfo } from "../features/Chat/activeFile";
20+
export {
21+
setOpenFiles,
22+
type OpenFilesState,
23+
} from "../features/OpenFiles/openFilesSlice";
1924
export {
2025
type Snippet,
2126
setSelectedSnippet,
2227
} from "../features/Chat/selectedSnippet";
2328
export type { FimDebugData } from "../services/refact/fim";
2429
export type { ChatHistoryItem } from "../features/History/historySlice";
30+
export { resetDiffApi } from "../services/refact/diffs";
2531
// TODO: re-exporting from redux seems to break things :/
2632
export type InitialState = {
2733
fim: FIMDebugState;
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
2+
3+
export type OpenFilesState = {
4+
files: string[];
5+
};
6+
7+
const initialState: OpenFilesState = {
8+
files: [],
9+
};
10+
11+
export const openFilesSlice = createSlice({
12+
name: "openFiles",
13+
initialState,
14+
reducers: {
15+
setOpenFiles: (state, action: PayloadAction<string[]>) => {
16+
state.files = action.payload;
17+
},
18+
},
19+
selectors: {
20+
selectOpenFiles: (state) => state.files,
21+
},
22+
});
23+
24+
export const { setOpenFiles } = openFilesSlice.actions;
25+
export const { selectOpenFiles } = openFilesSlice.selectors;

src/hooks/useEventBusForApp.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import { useConfig } from "./useConfig";
55
import { updateConfig } from "../features/Config/configSlice";
66
import { setFileInfo } from "../features/Chat/activeFile";
77
import { setSelectedSnippet } from "../features/Chat/selectedSnippet";
8-
import { newChatAction } from "../events";
8+
import { setOpenFiles } from "../features/OpenFiles/openFilesSlice";
9+
import { newChatAction } from "../features/Chat/Thread/actions";
910
import {
1011
isPageInHistory,
1112
push,
1213
selectPages,
1314
} from "../features/Pages/pagesSlice";
15+
import { diffApi, resetDiffApi } from "../services/refact/diffs";
1416

1517
export function useEventBusForApp() {
1618
const config = useConfig();
@@ -37,6 +39,14 @@ export function useEventBusForApp() {
3739
}
3840
dispatch(newChatAction(event.data.payload));
3941
}
42+
43+
if (setOpenFiles.match(event.data)) {
44+
dispatch(event.data);
45+
}
46+
47+
if (resetDiffApi.match(event.data)) {
48+
dispatch(diffApi.util.resetApiState());
49+
}
4050
};
4151

4252
window.addEventListener("message", listener);

src/services/refact/consts.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export const DOCUMENTATION_LIST = `/v1/docs-list`;
1515
export const DOCUMENTATION_ADD = `/v1/docs-add`;
1616
export const DOCUMENTATION_REMOVE = `/v1/docs-remove`;
1717
export const PING_URL = `/v1/ping`;
18+
export const PATCH_URL = `/v1/patch-single-file-from-ticket`;

0 commit comments

Comments
 (0)