From 09ebe770a9ed706989cc7733dc54d5caf4cf029d Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 30 Dec 2024 23:06:59 -0800 Subject: [PATCH 01/12] wip on /browse route --- .../web/src/app/browse/[...path]/page.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/web/src/app/browse/[...path]/page.tsx diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx new file mode 100644 index 00000000..b12b80da --- /dev/null +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { fetchFileSource } from '@/app/api/(client)/client'; +import { base64Decode } from '@/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + + +export default function BrowsePage({ params }: { params: { path: string[] } }) { + + // @TODO: This does not factor in branch... + // Also, we can probably do this in a server component. + const parsedParams = useMemo(() => { + const path = params.path.join('/'); + const idx = path.search(/\/-\/(tree|blob)\//); + if (idx === -1) { + console.log('No sentinal found'); + return; + } + + const repoName = path.substring(0, idx); + const { filePath, type } = (() => { + const fullPath = path.substring(idx + '/-/'.length); + const type = fullPath.startsWith('tree/') ? 'tree' : 'blob'; + if (type === 'tree') { + return { + filePath: fullPath.substring('tree/'.length), + type, + }; + } else { + return { + filePath: fullPath.substring('blob/'.length), + type, + }; + } + })(); + + return { + path: filePath, + type, + repoName, + } + }, [params.path]); + + const { data: source } = useQuery({ + queryKey: ["source", parsedParams?.path, parsedParams?.repoName, parsedParams?.type], + queryFn: async (): Promise => { + if (!parsedParams || parsedParams.type !== 'blob') { + return undefined; + } + + console.log(parsedParams); + + return fetchFileSource({ + fileName: parsedParams.path, + repository: parsedParams.repoName, + }).then(({ source }) => { + return base64Decode(source); + }); + } + }); + + return ( +
+ {params.path.join('/')} +
{source}
+
+ ) +} \ No newline at end of file From e389389b88e5afd05c2fe07dd8a37f2debbb902b Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 3 Jan 2025 09:01:59 -0800 Subject: [PATCH 02/12] Move data fetching into server component --- .../src/app/browse/[...path]/codePreview.tsx | 40 +++++ .../web/src/app/browse/[...path]/page.tsx | 155 ++++++++++++------ .../src/app/components/settingsDropdown.tsx | 2 + packages/web/src/lib/schemas.ts | 1 + packages/web/src/lib/server/searchService.ts | 7 +- 5 files changed, 150 insertions(+), 55 deletions(-) create mode 100644 packages/web/src/app/browse/[...path]/codePreview.tsx diff --git a/packages/web/src/app/browse/[...path]/codePreview.tsx b/packages/web/src/app/browse/[...path]/codePreview.tsx new file mode 100644 index 00000000..dfc33206 --- /dev/null +++ b/packages/web/src/app/browse/[...path]/codePreview.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; +import { useThemeNormalized } from "@/hooks/useThemeNormalized"; +import { search } from "@codemirror/search"; +import CodeMirror, { EditorView, ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { useMemo, useRef } from "react"; + + +interface CodePreviewProps { + source: string; + language: string; +} + +export const CodePreview = ({ + source, + language, +}: CodePreviewProps) => { + const editorRef = useRef(null); + const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); + const extensions = useMemo(() => { + return [ + syntaxHighlighting, + EditorView.lineWrapping, + search({ + top: true, + }), + ]; + }, [syntaxHighlighting]); + const { theme } = useThemeNormalized(); + + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx index b12b80da..4c2ff489 100644 --- a/packages/web/src/app/browse/[...path]/page.tsx +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -1,69 +1,118 @@ -'use client'; +import Image from "next/image"; +import logoDark from '@/public/sb_logo_dark.png'; +import logoLight from '@/public/sb_logo_light.png'; +import { SettingsDropdown } from '@/app/components/settingsDropdown'; +import { Separator } from '@/components/ui/separator'; +import { getFileSource } from '@/lib/server/searchService'; +import { base64Decode, isServiceError } from "@/lib/utils"; +import { SearchBar } from "@/app/components/searchBar"; +import { CodePreview } from "./codePreview"; -import { fetchFileSource } from '@/app/api/(client)/client'; -import { base64Decode } from '@/lib/utils'; -import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +interface BrowsePageProps { + params: { + path: string[]; + }; +} +export default async function BrowsePage({ + params, +}: BrowsePageProps) { + const rawPath = params.path.join('/'); + const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); + if (sentinalIndex === -1) { + // @todo : proper error handling + return ( + <> + No sentinal found + + ) + } -export default function BrowsePage({ params }: { params: { path: string[] } }) { - - // @TODO: This does not factor in branch... - // Also, we can probably do this in a server component. - const parsedParams = useMemo(() => { - const path = params.path.join('/'); - const idx = path.search(/\/-\/(tree|blob)\//); - if (idx === -1) { - console.log('No sentinal found'); - return; - } - - const repoName = path.substring(0, idx); - const { filePath, type } = (() => { - const fullPath = path.substring(idx + '/-/'.length); - const type = fullPath.startsWith('tree/') ? 'tree' : 'blob'; - if (type === 'tree') { + const repoName = rawPath.substring(0, sentinalIndex); + const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { + const path = rawPath.substring(sentinalIndex + '/-/'.length); + const pathType = path.startsWith('tree/') ? 'tree' : 'blob'; + switch (pathType) { + case 'tree': return { - filePath: fullPath.substring('tree/'.length), - type, + path: path.substring('tree/'.length), + pathType, }; - } else { + case 'blob': return { - filePath: fullPath.substring('blob/'.length), - type, + path: path.substring('blob/'.length), + pathType, }; - } - })(); - - return { - path: filePath, - type, - repoName, } - }, [params.path]); + })(); - const { data: source } = useQuery({ - queryKey: ["source", parsedParams?.path, parsedParams?.repoName, parsedParams?.type], - queryFn: async (): Promise => { - if (!parsedParams || parsedParams.type !== 'blob') { - return undefined; - } - - console.log(parsedParams); - - return fetchFileSource({ - fileName: parsedParams.path, - repository: parsedParams.repoName, - }).then(({ source }) => { - return base64Decode(source); - }); - } + if (pathType === 'tree') { + // @todo : proper tree handling + return ( + <> + Tree view not supported + + ) + } + + // @todo: this will depend on `pathType`. + const response = await getFileSource({ + fileName: path, + repository: repoName, + // @TODO: Incorporate branch in path + branch: 'HEAD' }); + if (isServiceError(response)) { + // @todo : proper error handling + return ( + <> + Error: {response.message} + + ) + } + return (
- {params.path.join('/')} -
{source}
+
+
+
+
+ {"Sourcebot + {"Sourcebot +
+ +
+ +
+ +
+ + {rawPath} + +
+ +
+
) } \ No newline at end of file diff --git a/packages/web/src/app/components/settingsDropdown.tsx b/packages/web/src/app/components/settingsDropdown.tsx index 6b1b5986..86b20497 100644 --- a/packages/web/src/app/components/settingsDropdown.tsx +++ b/packages/web/src/app/components/settingsDropdown.tsx @@ -1,3 +1,5 @@ +'use client'; + import { CodeIcon, Laptop, diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index e1741644..25526f6b 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -94,6 +94,7 @@ export const fileSourceRequestSchema = z.object({ export const fileSourceResponseSchema = z.object({ source: z.string(), + language: z.string(), }); diff --git a/packages/web/src/lib/server/searchService.ts b/packages/web/src/lib/server/searchService.ts index a4a9157b..6012bfa8 100644 --- a/packages/web/src/lib/server/searchService.ts +++ b/packages/web/src/lib/server/searchService.ts @@ -106,9 +106,12 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource return fileNotFound(fileName, repository); } - const source = files[0].Content ?? ''; + const file = files[0]; + const source = file.Content ?? ''; + const language = file.Language; return { - source + source, + language, } } From 5f9c33915c562f242c5b29ea967e1cba1af39c00 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 3 Jan 2025 09:11:37 -0800 Subject: [PATCH 03/12] refactor top bar into seperate component --- .../web/src/app/browse/[...path]/page.tsx | 30 ++----------- packages/web/src/app/components/topBar.tsx | 44 +++++++++++++++++++ packages/web/src/app/search/page.tsx | 40 ++--------------- 3 files changed, 52 insertions(+), 62 deletions(-) create mode 100644 packages/web/src/app/components/topBar.tsx diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx index 4c2ff489..c739eb26 100644 --- a/packages/web/src/app/browse/[...path]/page.tsx +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -7,6 +7,7 @@ import { getFileSource } from '@/lib/server/searchService'; import { base64Decode, isServiceError } from "@/lib/utils"; import { SearchBar } from "@/app/components/searchBar"; import { CodePreview } from "./codePreview"; +import { TopBar } from "@/app/components/topBar"; interface BrowsePageProps { params: { @@ -75,32 +76,9 @@ export default async function BrowsePage({ return (
-
-
-
- {"Sourcebot - {"Sourcebot -
- -
- -
+
diff --git a/packages/web/src/app/components/topBar.tsx b/packages/web/src/app/components/topBar.tsx new file mode 100644 index 00000000..3ca9bd10 --- /dev/null +++ b/packages/web/src/app/components/topBar.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import Image from "next/image"; +import logoLight from "@/public/sb_logo_light.png"; +import logoDark from "@/public/sb_logo_dark.png"; +import { SearchBar } from "./searchBar"; +import { SettingsDropdown } from "./settingsDropdown"; + +interface TopBarProps { + defaultSearchQuery?: string; +} + +export const TopBar = ({ + defaultSearchQuery +}: TopBarProps) => { + return ( +
+
+ + {"Sourcebot + {"Sourcebot + + +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/search/page.tsx index e8963e82..8e9350e5 100644 --- a/packages/web/src/app/search/page.tsx +++ b/packages/web/src/app/search/page.tsx @@ -8,23 +8,19 @@ import { import { Separator } from "@/components/ui/separator"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { useSearchHistory } from "@/hooks/useSearchHistory"; import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types"; import { createPathWithQueryParams } from "@/lib/utils"; import { SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import logoDark from "../../../public/sb_logo_dark.png"; -import logoLight from "../../../public/sb_logo_light.png"; +import { ImperativePanelHandle } from "react-resizable-panels"; import { getRepos, search } from "../api/(client)/client"; -import { SearchBar } from "../components/searchBar"; -import { SettingsDropdown } from "../components/settingsDropdown"; +import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; -import { ImperativePanelHandle } from "react-resizable-panels"; -import { useSearchHistory } from "@/hooks/useSearchHistory"; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; @@ -178,35 +174,7 @@ export default function SearchPage() {
{/* TopBar */}
-
-
-
{ - router.push("/"); - }} - > - {"Sourcebot - {"Sourcebot -
- -
- -
+ {!isLoading && (
From f93e0020fae60830e1b4c814bd571a72098fe8f8 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 3 Jan 2025 10:02:27 -0800 Subject: [PATCH 04/12] created fileHeader component for reuse --- .../src/app/browse/[...path]/codePreview.tsx | 1 + .../web/src/app/browse/[...path]/page.tsx | 45 ++++++---- .../web/src/app/components/fireHeader.tsx | 74 ++++++++++++++++ .../searchResultsPanel/fileMatchContainer.tsx | 88 ++++--------------- 4 files changed, 123 insertions(+), 85 deletions(-) create mode 100644 packages/web/src/app/components/fireHeader.tsx diff --git a/packages/web/src/app/browse/[...path]/codePreview.tsx b/packages/web/src/app/browse/[...path]/codePreview.tsx index dfc33206..27873bc1 100644 --- a/packages/web/src/app/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/browse/[...path]/codePreview.tsx @@ -34,6 +34,7 @@ export const CodePreview = ({ ref={editorRef} value={source} extensions={extensions} + readOnly={true} theme={theme === "dark" ? "dark" : "light"} /> ) diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx index c739eb26..13604cb0 100644 --- a/packages/web/src/app/browse/[...path]/page.tsx +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -1,13 +1,9 @@ -import Image from "next/image"; -import logoDark from '@/public/sb_logo_dark.png'; -import logoLight from '@/public/sb_logo_light.png'; -import { SettingsDropdown } from '@/app/components/settingsDropdown'; +import { FileHeader } from "@/app/components/fireHeader"; +import { TopBar } from "@/app/components/topBar"; import { Separator } from '@/components/ui/separator'; -import { getFileSource } from '@/lib/server/searchService'; +import { getFileSource, listRepositories } from '@/lib/server/searchService'; import { base64Decode, isServiceError } from "@/lib/utils"; -import { SearchBar } from "@/app/components/searchBar"; import { CodePreview } from "./codePreview"; -import { TopBar } from "@/app/components/topBar"; interface BrowsePageProps { params: { @@ -57,22 +53,36 @@ export default async function BrowsePage({ } // @todo: this will depend on `pathType`. - const response = await getFileSource({ + const fileSourceResponse = await getFileSource({ fileName: path, repository: repoName, - // @TODO: Incorporate branch in path + // @todo: Incorporate branch in path branch: 'HEAD' }); - if (isServiceError(response)) { + if (isServiceError(fileSourceResponse)) { + // @todo : proper error handling + return ( + <> + Error: {fileSourceResponse.message} + + ) + } + + const reposResponse = await listRepositories(); + if (isServiceError(reposResponse)) { // @todo : proper error handling return ( <> - Error: {response.message} + Error: {reposResponse.message} ) } + // @todo (bkellam) : We should probably have a endpoint to fetch repository metadata + // given it's name or id. + const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName); + return (
@@ -81,15 +91,18 @@ export default async function BrowsePage({ />
- - {rawPath} - +
) diff --git a/packages/web/src/app/components/fireHeader.tsx b/packages/web/src/app/components/fireHeader.tsx new file mode 100644 index 00000000..30ea8f03 --- /dev/null +++ b/packages/web/src/app/components/fireHeader.tsx @@ -0,0 +1,74 @@ +import { Repository } from "@/lib/types"; +import { getRepoCodeHostInfo } from "@/lib/utils"; +import { LaptopIcon } from "@radix-ui/react-icons"; +import clsx from "clsx"; +import Image from "next/image"; +import Link from "next/link"; + +interface FileHeaderProps { + repo?: Repository; + fileName: string; + fileNameHighlightRange?: { + from: number; + to: number; + } + branchDisplayName?: string; + branchDisplayTitle?: string; +} + +export const FileHeader = ({ + repo, + fileName, + fileNameHighlightRange, + branchDisplayName, + branchDisplayTitle, +}: FileHeaderProps) => { + + const info = getRepoCodeHostInfo(repo); + + return ( +
+ {info?.icon ? ( + {info.costHostName} + ): ( + + )} + + {info?.displayName} + + {branchDisplayName && ( + + {`@ ${branchDisplayName}`} + + )} + · +
+ + {!fileNameHighlightRange ? + fileName + : ( + <> + {fileName.slice(0, fileNameHighlightRange.from)} + + {fileName.slice(fileNameHighlightRange.from, fileNameHighlightRange.to)} + + {fileName.slice(fileNameHighlightRange.to)} + + )} + +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx index 82d39c55..496e9f3b 100644 --- a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -1,12 +1,10 @@ 'use client'; -import { getRepoCodeHostInfo } from "@/lib/utils"; -import { useCallback, useMemo } from "react"; -import Image from "next/image"; -import { DoubleArrowDownIcon, DoubleArrowUpIcon, LaptopIcon } from "@radix-ui/react-icons"; -import clsx from "clsx"; +import { FileHeader } from "@/app/components/fireHeader"; import { Separator } from "@/components/ui/separator"; import { Repository, SearchResultFile } from "@/lib/types"; +import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; +import { useCallback, useMemo } from "react"; import { FileMatch } from "./fileMatch"; export const MAX_MATCHES_TO_PREVIEW = 3; @@ -58,32 +56,9 @@ export const FileMatchContainer = ({ } } - return null; + return undefined; }, [matches]); - const { repoIcon, displayName, repoLink } = useMemo(() => { - const repo: Repository | undefined = repoMetadata[file.Repository]; - const info = getRepoCodeHostInfo(repo); - - if (info) { - return { - displayName: info.displayName, - repoLink: info.repoLink, - repoIcon: {info.costHostName} - } - } - - return { - displayName: file.Repository, - repoLink: undefined, - repoIcon: - } - }, [file.Repository, repoMetadata]); - const isMoreContentButtonVisible = useMemo(() => { return matchCount > MAX_MATCHES_TO_PREVIEW; }, [matchCount]); @@ -104,6 +79,14 @@ export const FileMatchContainer = ({ return file.Branches; }, [file.Branches]); + const branchDisplayName = useMemo(() => { + if (!isBranchFilteringEnabled || branches.length === 0) { + return undefined; + } + + return `${branches[0]}${branches.length > 1 ? ` +${branches.length - 1}` : ''}`; + }, [isBranchFilteringEnabled, branches]); + return (
@@ -114,46 +97,13 @@ export const FileMatchContainer = ({ onOpenFile(); }} > -
- {repoIcon} - { - if (repoLink) { - window.open(repoLink, "_blank"); - } - }} - > - {displayName} - - {isBranchFilteringEnabled && branches.length > 0 && ( - - {`@ ${branches[0]}`} - {branches.length > 1 && ` (+ ${branches.length - 1})`} - - )} - · -
- - {!fileNameRange ? - file.FileName - : ( - <> - {file.FileName.slice(0, fileNameRange.from)} - - {file.FileName.slice(fileNameRange.from, fileNameRange.to)} - - {file.FileName.slice(fileNameRange.to)} - - )} - -
-
+
{/* Matches */} From d16bb73be2aaee0daa5060653e4eaae2a42cef61 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 3 Jan 2025 15:34:14 -0800 Subject: [PATCH 05/12] Add error components --- .../web/src/app/browse/[...path]/page.tsx | 122 ++++++++++++------ .../web/src/app/components/pageNotFound.tsx | 18 +++ packages/web/src/app/not-found.tsx | 7 + 3 files changed, 106 insertions(+), 41 deletions(-) create mode 100644 packages/web/src/app/components/pageNotFound.tsx create mode 100644 packages/web/src/app/not-found.tsx diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx index 13604cb0..ce54c795 100644 --- a/packages/web/src/app/browse/[...path]/page.tsx +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -4,6 +4,9 @@ import { Separator } from '@/components/ui/separator'; import { getFileSource, listRepositories } from '@/lib/server/searchService'; import { base64Decode, isServiceError } from "@/lib/utils"; import { CodePreview } from "./codePreview"; +import { PageNotFound } from "@/app/components/pageNotFound"; +import { ErrorCode } from "@/lib/errorCodes"; +import { LuFileX2, LuBookX } from "react-icons/lu"; interface BrowsePageProps { params: { @@ -17,12 +20,7 @@ export default async function BrowsePage({ const rawPath = params.path.join('/'); const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); if (sentinalIndex === -1) { - // @todo : proper error handling - return ( - <> - No sentinal found - - ) + return ; } const repoName = rawPath.substring(0, sentinalIndex); @@ -43,6 +41,19 @@ export default async function BrowsePage({ } })(); + // @todo (bkellam) : We should probably have a endpoint to fetch repository metadata + // given it's name or id. + const reposResponse = await listRepositories(); + if (isServiceError(reposResponse)) { + // @todo : proper error handling + return ( + <> + Error: {reposResponse.message} + + ) + } + const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName); + if (pathType === 'tree') { // @todo : proper tree handling return ( @@ -52,6 +63,55 @@ export default async function BrowsePage({ ) } + + + return ( +
+
+ + + {repo && ( + <> +
+ +
+ + + )} +
+ {repo === undefined ? ( +
+
+ + Repository not found +
+
+ ) : ( + + )} +
+ ) +} + +interface CodePreviewWrapper { + path: string, + repoName: string, +} + +const CodePreviewWrapper = async ({ + path, + repoName, +}: CodePreviewWrapper) => { // @todo: this will depend on `pathType`. const fileSourceResponse = await getFileSource({ fileName: path, @@ -61,49 +121,29 @@ export default async function BrowsePage({ }); if (isServiceError(fileSourceResponse)) { - // @todo : proper error handling - return ( - <> - Error: {fileSourceResponse.message} - - ) - } + if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { + return ( +
+
+ + File not found +
+
+ ) + } - const reposResponse = await listRepositories(); - if (isServiceError(reposResponse)) { // @todo : proper error handling return ( <> - Error: {reposResponse.message} + Error: {fileSourceResponse.message} ) } - // @todo (bkellam) : We should probably have a endpoint to fetch repository metadata - // given it's name or id. - const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName); - return ( -
-
- - -
- -
- -
- -
+ ) } \ No newline at end of file diff --git a/packages/web/src/app/components/pageNotFound.tsx b/packages/web/src/app/components/pageNotFound.tsx new file mode 100644 index 00000000..8878f85b --- /dev/null +++ b/packages/web/src/app/components/pageNotFound.tsx @@ -0,0 +1,18 @@ +import { Separator } from "@/components/ui/separator" + +export const PageNotFound = () => { + return ( +
+
+
+

404

+ +

Page not found

+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx new file mode 100644 index 00000000..b777ee1b --- /dev/null +++ b/packages/web/src/app/not-found.tsx @@ -0,0 +1,7 @@ +import { PageNotFound } from "./components/pageNotFound"; + +export default function NotFound() { + return ( + + ) +} \ No newline at end of file From 116a33c1a4c52e1a9ba467b148b67fe1d00192bc Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 6 Jan 2025 14:31:07 -0800 Subject: [PATCH 06/12] wip on adding context menu --- packages/web/package.json | 1 + .../src/app/browse/[...path]/codePreview.tsx | 50 ++++-- .../src/app/browse/[...path]/contextMenu.tsx | 144 ++++++++++++++++++ .../web/src/app/browse/[...path]/page.tsx | 2 + yarn.lock | 37 +++-- 5 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 packages/web/src/app/browse/[...path]/contextMenu.tsx diff --git a/packages/web/package.json b/packages/web/package.json index be0cd38d..4878fc58 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,6 +35,7 @@ "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.33.0", + "@floating-ui/react": "^0.27.2", "@hookform/resolvers": "^3.9.0", "@iconify/react": "^5.1.0", "@iizukak/codemirror-lang-wgsl": "^0.3.0", diff --git a/packages/web/src/app/browse/[...path]/codePreview.tsx b/packages/web/src/app/browse/[...path]/codePreview.tsx index 27873bc1..54703c7d 100644 --- a/packages/web/src/app/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/browse/[...path]/codePreview.tsx @@ -3,11 +3,13 @@ import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; import { useThemeNormalized } from "@/hooks/useThemeNormalized"; import { search } from "@codemirror/search"; -import CodeMirror, { EditorView, ReactCodeMirrorRef } from "@uiw/react-codemirror"; -import { useMemo, useRef } from "react"; - +import CodeMirror, { EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; +import { useMemo, useRef, useState } from "react"; +import { ContextMenu } from "./contextMenu"; interface CodePreviewProps { + path: string; + repoName: string; source: string; language: string; } @@ -15,9 +17,14 @@ interface CodePreviewProps { export const CodePreview = ({ source, language, + path, + repoName, }: CodePreviewProps) => { const editorRef = useRef(null); const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); + + const [currentSelection, setCurrentSelection] = useState(); + const extensions = useMemo(() => { return [ syntaxHighlighting, @@ -25,17 +32,38 @@ export const CodePreview = ({ search({ top: true, }), + EditorView.updateListener.of((update: ViewUpdate) => { + if (!update.selectionSet) { + return; + } + + setCurrentSelection(update.state.selection.main); + }) ]; }, [syntaxHighlighting]); + const { theme } = useThemeNormalized(); return ( - + <> + + {editorRef.current && editorRef.current.view && currentSelection && ( + + )} + + ) -} \ No newline at end of file +} + diff --git a/packages/web/src/app/browse/[...path]/contextMenu.tsx b/packages/web/src/app/browse/[...path]/contextMenu.tsx new file mode 100644 index 00000000..ad08d1c4 --- /dev/null +++ b/packages/web/src/app/browse/[...path]/contextMenu.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { createPathWithQueryParams } from "@/lib/utils"; +import { autoPlacement, computePosition, offset, VirtualElement } from "@floating-ui/react"; +import { Link2Icon } from "@radix-ui/react-icons"; +import { EditorView, SelectionRange } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +interface ContextMenuProps { + view: EditorView; + selection: SelectionRange; + repoName: string; + path: string; +} + +export const ContextMenu = ({ + view, + selection, + repoName, + path, +}: ContextMenuProps) => { + const ref = useRef(null); + const [isMouseDown, setIsMouseDown] = useState(false); + const { toast } = useToast(); + + const { show, hide } = useMemo(() => { + return { + show: () => ref.current?.classList.remove('hidden'), + hide: () => ref.current?.classList.add('hidden'), + } + }, []); + + useEffect(() => { + const onMouseDown = () => { + setIsMouseDown(true); + } + + const onMouseUp = () => { + setIsMouseDown(false); + } + + view.dom.addEventListener('mousedown', onMouseDown); + view.dom.addEventListener('mouseup', onMouseUp); + return () => { + view.dom.removeEventListener('mousedown', onMouseDown); + view.dom.removeEventListener('mouseup', onMouseUp); + } + }, [view.dom]); + + useEffect(() => { + if (selection.empty || isMouseDown) { + hide(); + return; + } + + const { from, to } = selection; + const start = view.coordsAtPos(from); + const end = view.coordsAtPos(to); + if (!start || !end) { + return; + } + + const selectionElement: VirtualElement = { + getBoundingClientRect: () => { + + const { top, left } = start; + const { bottom, right } = end; + + return { + x: left, + y: top, + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + } + } + } + + if (ref.current) { + computePosition(selectionElement, ref.current, { + middleware: [ + offset(5), + autoPlacement({ + boundary: view.dom, + padding: 5, + allowedPlacements: ['bottom'], + }) + ], + }).then(({ x, y }) => { + if (ref.current) { + ref.current.style.left = `${x}px`; + ref.current.style.top = `${y}px`; + show(); + } + }); + } + + }, [hide, isMouseDown, selection, show, view]); + + const onCopyLinkToSelection = useCallback(() => { + const toLineAndColumn = (pos: number) => { + const lineInfo = view.state.doc.lineAt(pos); + return { + line: lineInfo.number, + column: pos - lineInfo.from + 1, + } + } + + const from = toLineAndColumn(selection.from); + const to = toLineAndColumn(selection.to); + + const url = createPathWithQueryParams(`${window.location.origin}/browse/${repoName}/-/blob/${path}`, + ['from', `${from?.line}:${from?.column}`], + ['to', `${to?.line}:${to?.column}`], + ); + + navigator.clipboard.writeText(url); + toast({ + description: "✅ Copied link to selection", + }); + hide(); + }, [hide, path, repoName, selection.from, selection.to, toast, view.state.doc]); + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx index ce54c795..6c7da641 100644 --- a/packages/web/src/app/browse/[...path]/page.tsx +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -144,6 +144,8 @@ const CodePreviewWrapper = async ({ ) } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 38ac6ea8..72b9d77d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -642,13 +642,22 @@ "@floating-ui/core" "^1.6.0" "@floating-ui/utils" "^0.2.8" -"@floating-ui/react-dom@^2.0.0": +"@floating-ui/react-dom@^2.0.0", "@floating-ui/react-dom@^2.1.2": version "2.1.2" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31" integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A== dependencies: "@floating-ui/dom" "^1.0.0" +"@floating-ui/react@^0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.2.tgz#901a04e93061c427d45b69a29c99f641a8b3a7bc" + integrity sha512-k/yP6a9K9QwhLfIu87iUZxCH6XN5z5j/VUHHq0dEnbZYY2Y9jz68E/LXFtK8dkiaYltS2WYohnyKC0VcwVneVg== + dependencies: + "@floating-ui/react-dom" "^2.1.2" + "@floating-ui/utils" "^0.2.8" + tabbable "^6.0.0" + "@floating-ui/utils@^0.2.8": version "0.2.8" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" @@ -5527,16 +5536,8 @@ string-argv@^0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5633,14 +5634,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5718,6 +5712,11 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tabbable@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + tailwind-merge@^2.5.2: version "2.5.3" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.3.tgz#579546e14ddda24462e0303acd8798c50f5511bb" From 8eceae072d3fc92bddc7461a14866c83ef356d1e Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 6 Jan 2025 16:27:10 -0800 Subject: [PATCH 07/12] Add highlighting support --- .../src/app/browse/[...path]/codePreview.tsx | 98 +++++++++++++++++-- .../src/app/browse/[...path]/contextMenu.tsx | 3 +- .../web/src/app/browse/[...path]/page.tsx | 2 - .../codePreviewPanel/codePreview.tsx | 52 +++++----- .../components/codePreviewPanel/index.tsx | 1 + packages/web/src/hooks/useKeymapExtension.ts | 26 +++++ 6 files changed, 146 insertions(+), 36 deletions(-) create mode 100644 packages/web/src/hooks/useKeymapExtension.ts diff --git a/packages/web/src/app/browse/[...path]/codePreview.tsx b/packages/web/src/app/browse/[...path]/codePreview.tsx index 54703c7d..1693fe9e 100644 --- a/packages/web/src/app/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/browse/[...path]/codePreview.tsx @@ -1,10 +1,13 @@ 'use client'; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useKeymapExtension } from "@/hooks/useKeymapExtension"; +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; import { useThemeNormalized } from "@/hooks/useThemeNormalized"; import { search } from "@codemirror/search"; -import CodeMirror, { EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; -import { useMemo, useRef, useState } from "react"; +import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, StateField, ViewUpdate } from "@uiw/react-codemirror"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ContextMenu } from "./contextMenu"; interface CodePreviewProps { @@ -22,33 +25,108 @@ export const CodePreview = ({ }: CodePreviewProps) => { const editorRef = useRef(null); const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); - const [currentSelection, setCurrentSelection] = useState(); + const keymapExtension = useKeymapExtension(editorRef.current?.view); + const [isEditorCreated, setIsEditorCreated] = useState(false); + + const highlightRangeQuery = useNonEmptyQueryParam('highlightRange'); + const highlightRange = useMemo(() => { + if (!highlightRangeQuery) { + return; + } + + const rangeRegex = /^\d+:\d+,\d+:\d+$/; + if (!rangeRegex.test(highlightRangeQuery)) { + return; + } + + const [start, end] = highlightRangeQuery.split(',').map((range) => { + return range.split(':').map((val) => parseInt(val, 10)); + }); + + return { + start: { + line: start[0], + character: start[1], + }, + end: { + line: end[0], + character: end[1], + } + } + }, [highlightRangeQuery]); const extensions = useMemo(() => { + const highlightDecoration = Decoration.mark({ + class: "cm-searchMatch-selected", + }); + return [ syntaxHighlighting, EditorView.lineWrapping, + keymapExtension, search({ top: true, }), EditorView.updateListener.of((update: ViewUpdate) => { - if (!update.selectionSet) { - return; + if (update.selectionSet) { + setCurrentSelection(update.state.selection.main); } + }), + StateField.define({ + create(state) { + if (!highlightRange) { + return Decoration.none; + } + + const { start, end } = highlightRange; + const from = state.doc.line(start.line).from + start.character - 1; + const to = state.doc.line(end.line).from + end.character - 1; - setCurrentSelection(update.state.selection.main); - }) + return Decoration.set([ + highlightDecoration.range(from, to), + ]); + }, + update(deco, tr) { + return deco.map(tr.changes); + }, + provide: (field) => EditorView.decorations.from(field), + }), ]; - }, [syntaxHighlighting]); + }, [keymapExtension, syntaxHighlighting, highlightRange]); + + useEffect(() => { + if (!highlightRange || !editorRef.current || !editorRef.current.state) { + return; + } + + const doc = editorRef.current.state.doc; + const { start, end } = highlightRange; + const from = doc.line(start.line).from + start.character - 1; + const to = doc.line(end.line).from + end.character - 1; + const selection = EditorSelection.range(from, to); + + editorRef.current.view?.dispatch({ + effects: [ + EditorView.scrollIntoView(selection, { y: "center" }), + ] + }); + // @note: we need to include `isEditorCreated` in the dependency array since + // a race-condition can happen if the `highlightRange` is resolved before the + // editor is created. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [highlightRange, isEditorCreated]); const { theme } = useThemeNormalized(); return ( - <> + { + setIsEditorCreated(true); + }} value={source} extensions={extensions} readOnly={true} @@ -63,7 +141,7 @@ export const CodePreview = ({ /> )} - + ) } diff --git a/packages/web/src/app/browse/[...path]/contextMenu.tsx b/packages/web/src/app/browse/[...path]/contextMenu.tsx index ad08d1c4..5bf30365 100644 --- a/packages/web/src/app/browse/[...path]/contextMenu.tsx +++ b/packages/web/src/app/browse/[...path]/contextMenu.tsx @@ -115,8 +115,7 @@ export const ContextMenu = ({ const to = toLineAndColumn(selection.to); const url = createPathWithQueryParams(`${window.location.origin}/browse/${repoName}/-/blob/${path}`, - ['from', `${from?.line}:${from?.column}`], - ['to', `${to?.line}:${to?.column}`], + ['highlightRange', `${from?.line}:${from?.column},${to?.line}:${to?.column}`], ); navigator.clipboard.writeText(url); diff --git a/packages/web/src/app/browse/[...path]/page.tsx b/packages/web/src/app/browse/[...path]/page.tsx index 6c7da641..bd221625 100644 --- a/packages/web/src/app/browse/[...path]/page.tsx +++ b/packages/web/src/app/browse/[...path]/page.tsx @@ -62,8 +62,6 @@ export default async function BrowsePage({ ) } - - return (
diff --git a/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx index e366e585..5657448e 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx +++ b/packages/web/src/app/search/components/codePreviewPanel/codePreview.tsx @@ -1,21 +1,19 @@ 'use client'; +import { ContextMenu } from "@/app/browse/[...path]/contextMenu"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency"; -import { useKeymapType } from "@/hooks/useKeymapType"; +import { useKeymapExtension } from "@/hooks/useKeymapExtension"; import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; import { useThemeNormalized } from "@/hooks/useThemeNormalized"; import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension"; import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension"; import { SearchResultFileMatch } from "@/lib/types"; -import { defaultKeymap } from "@codemirror/commands"; import { search } from "@codemirror/search"; -import { EditorView, keymap } from "@codemirror/view"; +import { EditorView } from "@codemirror/view"; import { Cross1Icon, FileIcon } from "@radix-ui/react-icons"; import { Scrollbar } from "@radix-ui/react-scroll-area"; -import { vim } from "@replit/codemirror-vim"; -import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import CodeMirror, { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror'; import clsx from "clsx"; import { ArrowDown, ArrowUp } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -30,6 +28,7 @@ export interface CodePreviewFile { interface CodePreviewProps { file?: CodePreviewFile; + repoName?: string; selectedMatchIndex: number; onSelectedMatchIndexChange: (index: number) => void; onClose: () => void; @@ -37,30 +36,19 @@ interface CodePreviewProps { export const CodePreview = ({ file, + repoName, selectedMatchIndex, onSelectedMatchIndexChange, onClose, }: CodePreviewProps) => { const editorRef = useRef(null); - const [keymapType] = useKeymapType(); const { theme } = useThemeNormalized(); const [gutterWidth, setGutterWidth] = useState(0); - const keymapExtension = useExtensionWithDependency( - editorRef.current?.view ?? null, - () => { - switch (keymapType) { - case "default": - return keymap.of(defaultKeymap); - case "vim": - return vim(); - } - }, - [keymapType] - ); - + const keymapExtension = useKeymapExtension(editorRef.current?.view); const syntaxHighlighting = useSyntaxHighlightingExtension(file?.language ?? '', editorRef.current?.view); + const [currentSelection, setCurrentSelection] = useState(); const extensions = useMemo(() => { return [ @@ -72,12 +60,17 @@ export const CodePreview = ({ search({ top: true, }), - EditorView.updateListener.of(update => { + EditorView.updateListener.of((update) => { const width = update.view.plugin(gutterWidthExtension)?.width; if (width) { setGutterWidth(width); } }), + EditorView.updateListener.of((update) => { + if (update.selectionSet) { + setCurrentSelection(update.state.selection.main); + } + }) ]; }, [keymapExtension, syntaxHighlighting]); @@ -182,7 +175,22 @@ export const CodePreview = ({ value={file?.content} theme={theme === "dark" ? "dark" : "light"} extensions={extensions} - /> + > + { + editorRef.current?.view && + file?.filepath && + repoName && + currentSelection && + ( + + ) + } + diff --git a/packages/web/src/app/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/search/components/codePreviewPanel/index.tsx index 30ae2f43..2b8ad808 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/search/components/codePreviewPanel/index.tsx @@ -71,6 +71,7 @@ export const CodePreviewPanel = ({ return ( { + const [keymapType] = useKeymapType(); + + const extension = useExtensionWithDependency( + view ?? null, + () => { + switch (keymapType) { + case "default": + return keymap.of(defaultKeymap); + case "vim": + return vim(); + } + }, + [keymapType] + ); + + return extension; +} \ No newline at end of file From c4de85d18652eb0bc68d10dd35cebff7905750ed Mon Sep 17 00:00:00 2001 From: bkellam Date: Mon, 6 Jan 2025 16:50:58 -0800 Subject: [PATCH 08/12] Moved context menu component into shared place. Also added proper scrolling for it --- .../src/app/browse/[...path]/codePreview.tsx | 4 +- .../editorContextMenu.tsx} | 51 ++++++++----------- .../codePreviewPanel/codePreview.tsx | 10 ++-- 3 files changed, 30 insertions(+), 35 deletions(-) rename packages/web/src/app/{browse/[...path]/contextMenu.tsx => components/editorContextMenu.tsx} (71%) diff --git a/packages/web/src/app/browse/[...path]/codePreview.tsx b/packages/web/src/app/browse/[...path]/codePreview.tsx index 1693fe9e..7baf7e0e 100644 --- a/packages/web/src/app/browse/[...path]/codePreview.tsx +++ b/packages/web/src/app/browse/[...path]/codePreview.tsx @@ -8,7 +8,7 @@ import { useThemeNormalized } from "@/hooks/useThemeNormalized"; import { search } from "@codemirror/search"; import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, StateField, ViewUpdate } from "@uiw/react-codemirror"; import { useEffect, useMemo, useRef, useState } from "react"; -import { ContextMenu } from "./contextMenu"; +import { EditorContextMenu } from "../../components/editorContextMenu"; interface CodePreviewProps { path: string; @@ -133,7 +133,7 @@ export const CodePreview = ({ theme={theme === "dark" ? "dark" : "light"} > {editorRef.current && editorRef.current.view && currentSelection && ( - { const ref = useRef(null); - const [isMouseDown, setIsMouseDown] = useState(false); const { toast } = useToast(); - const { show, hide } = useMemo(() => { - return { - show: () => ref.current?.classList.remove('hidden'), - hide: () => ref.current?.classList.add('hidden'), - } - }, []); - useEffect(() => { - const onMouseDown = () => { - setIsMouseDown(true); + if (selection.empty) { + ref.current?.classList.add('hidden'); + } else { + ref.current?.classList.remove('hidden'); } + }, [selection.empty]); - const onMouseUp = () => { - setIsMouseDown(false); - } - - view.dom.addEventListener('mousedown', onMouseDown); - view.dom.addEventListener('mouseup', onMouseUp); - return () => { - view.dom.removeEventListener('mousedown', onMouseDown); - view.dom.removeEventListener('mouseup', onMouseUp); - } - }, [view.dom]); useEffect(() => { - if (selection.empty || isMouseDown) { - hide(); + if (selection.empty) { return; } @@ -95,12 +78,11 @@ export const ContextMenu = ({ if (ref.current) { ref.current.style.left = `${x}px`; ref.current.style.top = `${y}px`; - show(); } }); } - }, [hide, isMouseDown, selection, show, view]); + }, [selection, view]); const onCopyLinkToSelection = useCallback(() => { const toLineAndColumn = (pos: number) => { @@ -122,13 +104,22 @@ export const ContextMenu = ({ toast({ description: "✅ Copied link to selection", }); - hide(); - }, [hide, path, repoName, selection.from, selection.to, toast, view.state.doc]); + + // Reset the selection + view.dispatch( + { + selection: { + anchor: selection.to, + head: selection.to, + } + } + ) + }, [path, repoName, selection.from, selection.to, toast, view]); return (