diff --git a/apps/lite/AGENTS.md b/apps/lite/AGENTS.md index 9db55eccb2..2bbb952581 100644 --- a/apps/lite/AGENTS.md +++ b/apps/lite/AGENTS.md @@ -5,3 +5,19 @@ Typechecking is the fastest way to validate that everything is okay. Always run ```console $ pnpm -F @gitbutler/lite check ``` + +## Components + +Memoization utilities such as `useMemo`, `useCallback`, and `React.memo` are redundant as we use React Compiler. + +Component definitions should follow this pattern, optionally destructuring `p`: + +```tsx +type Props = { + ... +}; + +export const MyComponent: FC = (p) => { + // [...] +}; +``` diff --git a/apps/lite/ui/src/ChangeUnit.ts b/apps/lite/ui/src/ChangeUnit.ts index be4587fd2b..e844287398 100644 --- a/apps/lite/ui/src/ChangeUnit.ts +++ b/apps/lite/ui/src/ChangeUnit.ts @@ -1,9 +1,9 @@ export type ChangeUnit = | { - _tag: "commit"; + _tag: "Commit"; commitId: string; } | { - _tag: "changes"; + _tag: "Changes"; stackId: string | null; }; diff --git a/apps/lite/ui/src/global.css b/apps/lite/ui/src/global.css index 0851817c55..8d1398f6de 100644 --- a/apps/lite/ui/src/global.css +++ b/apps/lite/ui/src/global.css @@ -11,13 +11,19 @@ body { input, textarea, select { + padding: 0; + border: none; + background: none; + color: inherit; font: inherit; line-height: inherit; } button { - border: 1px solid; - border-color: black; + padding: 0; + border: none; + background: none; + color: inherit; font: inherit; line-height: inherit; text-align: left; @@ -42,7 +48,8 @@ p { margin-block: 0; } -textarea, -button { - padding: 2px 4px; +ul, +ol { + padding-left: 0; + list-style: none; } diff --git a/apps/lite/ui/src/routes/ProjectPanelLayout.tsx b/apps/lite/ui/src/routes/ProjectPanelLayout.tsx index 7459af368a..d6d10aab91 100644 --- a/apps/lite/ui/src/routes/ProjectPanelLayout.tsx +++ b/apps/lite/ui/src/routes/ProjectPanelLayout.tsx @@ -72,6 +72,7 @@ export const ProjectPanelLayout: FC<{
- ); -}; +}) => ( +
+ + {isAnyFileSelected && ( +
+ Loading changed details…
}> + ( +
+ toggleFileSelect(change.path)} /> +
+ )} + /> + +
+ )} + +); const BranchDetailsC: FC<{ projectId: string; @@ -116,6 +141,7 @@ const BranchDetailsC: FC<{ isCommitSelected: (commitId: string) => boolean; isCommitAnyFileSelected: (commitId: string) => boolean; isCommitFileSelected: (commitId: string, path: string) => boolean; + toggleCommitExpanded: (commitId: string) => Promise | void; toggleCommitSelection: (commitId: string) => void; toggleCommitFileSelection: (commitId: string, path: string) => void; }> = ({ @@ -125,6 +151,7 @@ const BranchDetailsC: FC<{ isCommitSelected, isCommitAnyFileSelected, isCommitFileSelected, + toggleCommitExpanded, toggleCommitSelection, toggleCommitFileSelection, }) => { @@ -143,6 +170,7 @@ const BranchDetailsC: FC<{ isSelected={isCommitSelected(commit.id)} isAnyFileSelected={isCommitAnyFileSelected(commit.id)} isFileSelected={(path) => isCommitFileSelected(commit.id, path)} + toggleExpand={() => toggleCommitExpanded(commit.id)} toggleSelect={() => { toggleCommitSelection(commit.id); }} @@ -173,7 +201,7 @@ const CommitFileDiff: FC<{ projectId={projectId} change={change} renderHunk={(hunk, patch) => ( - + )} /> ); @@ -190,7 +218,7 @@ const CommitDiff: FC<{ if (data.changes.length === 0) return null; return ( -
    +
      {data.changes.map((change) => (
    • {change.path}
      @@ -200,7 +228,7 @@ const CommitDiff: FC<{ renderHunk={(hunk, patch) => ( @@ -225,7 +253,7 @@ const ShowBranch: FC<{ {data.changes.length === 0 ? (
      No file changes.
      ) : ( -
        +
          {data.changes.map((change) => (
        • {change.path}
          @@ -236,7 +264,7 @@ const ShowBranch: FC<{ @@ -267,24 +295,20 @@ const Preview: FC<{ if (selection === null) return null; - if (selection.commitId !== undefined && selection.path !== undefined) - return ( - - ); - - if (selection.commitId !== undefined) - return ; - - if (selectedBranchRef !== null) - return ( - - ); - - return
          No branch diff available.
          ; + return Match.value(selection).pipe( + Match.tag("Branch", ({ branchName }) => + selectedBranchRef !== null ? ( + + ) : ( +
          No branch diff available.
          + ), + ), + Match.tag("Commit", ({ commitId }) => ), + Match.tag("CommitFile", ({ commitId, path }) => ( + + )), + Match.exhaustive, + ); }; const ProjectBranchesPage: FC = () => { @@ -293,6 +317,7 @@ const ProjectBranchesPage: FC = () => { const { data: projects } = useSuspenseQuery(listProjectsQueryOptions()); const project = projects.find((project) => project.id === projectId); const { data: branches } = useSuspenseQuery(listBranchesQueryOptions(projectId)); + const queryClient = useQueryClient(); const applyBranch = useMutation(applyBranchMutationOptions); const unapplyStack = useMutation(unapplyStackMutationOptions); @@ -301,31 +326,67 @@ const ProjectBranchesPage: FC = () => { `project:${projectId}:branches:selection`, { defaultValue: null }, ); - const selection = _selection ? normalizeSelectionForBranches(_selection, sortedBranches) : null; + const selection = + (_selection ? normalizeSelectionForBranches(_selection, sortedBranches) : null) ?? + getDefaultSelection(sortedBranches); const selectedBranch = sortedBranches.find((branch) => branch.name === selection?.branchName); const selectedRemote = selectedBranch && !selectedBranch.hasLocal ? selectedBranch.remotes[0] : null; + const isBranchSelected = (branchName: string) => + selection?._tag === "Branch" && selection.branchName === branchName; + const isBranchSelectedWithin = (branchName: string) => + (selection?._tag === "Commit" || selection?._tag === "CommitFile") && + selection.branchName === branchName; + const isCommitSelected = (branchName: string, commitId: string) => - selection?.branchName === branchName && - selection.commitId === commitId && - selection.path === undefined; + selection?._tag === "Commit" && + selection.branchName === branchName && + selection.commitId === commitId; const isCommitAnyFileSelected = (branchName: string, commitId: string) => - selection?.branchName === branchName && - selection.commitId === commitId && - selection.path !== undefined; + selection?._tag === "CommitFile" && + selection.branchName === branchName && + selection.commitId === commitId; const isCommitFileSelected = (branchName: string, commitId: string, path: string) => - selection?.branchName === branchName && + selection?._tag === "CommitFile" && + selection.branchName === branchName && selection.commitId === commitId && selection.path === path; + const toggleCommitSelection = (branchName: string, commitId: string) => { - select(isCommitSelected(branchName, commitId) ? { branchName } : { branchName, commitId }); + select( + isCommitSelected(branchName, commitId) + ? { _tag: "Branch", branchName } + : { _tag: "Commit", branchName, commitId }, + ); + }; + const toggleCommitExpanded = async (branchName: string, commitId: string) => { + if (isCommitAnyFileSelected(branchName, commitId)) { + select({ _tag: "Commit", branchName, commitId }); + return; + } + + const commitDetails = await queryClient.ensureQueryData( + commitDetailsWithLineStatsQueryOptions({ projectId, commitId }), + ); + const firstPath = commitDetails.changes[0]?.path; + + select( + firstPath !== undefined + ? { _tag: "CommitFile", branchName, commitId, path: firstPath } + : { _tag: "Commit", branchName, commitId }, + ); }; const toggleCommitFileSelection = (branchName: string, commitId: string, path: string) => { select( isCommitFileSelected(branchName, commitId, path) - ? { branchName, commitId } - : { branchName, commitId, path }, + ? { _tag: "Commit", branchName, commitId } + : { _tag: "CommitFile", branchName, commitId, path }, + ); + }; + const toggleBranchSelection = (branchName: string) => { + select((selected) => + selected?.branchName === branchName ? null : { _tag: "Branch", branchName }, ); }; @@ -355,16 +416,22 @@ const ProjectBranchesPage: FC = () => { {sortedBranches.map((branch) => { const ref = getBranchRef(branch); const stackId = branch.stack?.id; - const isSelected = selectedBranch?.name === branch.name; + const isSelected = isBranchSelected(branch.name); + const isSelectedWithin = isBranchSelectedWithin(branch.name); return (
        • @@ -956,7 +1104,7 @@ const CommitMoveToBranchTarget: FC< render, ref: dropRef, props: mergeProps(props, { - style: { ...(isDropTarget && { outline: "2px dashed" }) }, + style: { ...(isDropTarget && { outline: "2px dashed black" }) }, }), }); @@ -975,9 +1123,12 @@ const CommitMoveToBranchTarget: FC< const StackC: FC<{ projectId: string; stack: Stack; + isBranchSelected: (stackId: string, branchRef: string) => boolean; + toggleBranchSelection: (stackId: string, branchName: string, branchRef: string) => void; isCommitSelected: (commitId: string) => boolean; isCommitAnyFileSelected: (commitId: string) => boolean; isChangeUnitFileSelected: (changeUnit: ChangeUnit, path: string) => boolean; + toggleCommitExpanded: (commitId: string) => Promise | void; toggleCommitSelection: (commitId: string) => void; toggleChangeUnitFileSelection: (changeUnit: ChangeUnit, path: string) => void; highlightedCommitIds: Set; @@ -985,9 +1136,12 @@ const StackC: FC<{ }> = ({ projectId, stack, + isBranchSelected, + toggleBranchSelection, isCommitSelected, isCommitAnyFileSelected, isChangeUnitFileSelected, + toggleCommitExpanded, toggleCommitSelection, toggleChangeUnitFileSelection, highlightedCommitIds, @@ -1003,7 +1157,7 @@ const StackC: FC<{ // oxlint-disable-next-line typescript/no-non-null-assertion -- [tag:stack-id-required] const stackId = stack.id!; - const changesChangeUnit: ChangeUnit = { _tag: "changes", stackId }; + const changesChangeUnit: ChangeUnit = { _tag: "Changes", stackId }; return (
          @@ -1036,20 +1190,39 @@ const StackC: FC<{
            {stack.segments.map((segment) => { const branchName = segment.refName?.displayName ?? "Untitled"; + const branchRef = getSegmentBranchRef(segment); const anchorRef = segment.refName ? segment.refName.fullNameBytes : null; return (
          • {branchName}} + render={ +

            + {branchRef !== null ? ( + + ) : ( + branchName + )} +

            + } /> -

            Commits

            {(commit, index) => { const changeUnit: ChangeUnit = { - _tag: "commit", + _tag: "Commit", commitId: commit.id, }; return ( @@ -1062,6 +1235,7 @@ const StackC: FC<{ isSelected={isCommitSelected(commit.id)} isAnyFileSelected={isCommitAnyFileSelected(commit.id)} isFileSelected={(path) => isChangeUnitFileSelected(changeUnit, path)} + toggleExpand={() => toggleCommitExpanded(commit.id)} toggleSelect={() => { toggleCommitSelection(commit.id); }} @@ -1092,13 +1266,22 @@ const ProjectPage: FC = () => { // TODO: handle project not found error. or only run when project is not null? waterfall. const { data: headInfo } = useSuspenseQuery(headInfoQueryOptions(projectId)); + const { data: worktreeChanges } = useSuspenseQuery(changesInWorktreeQueryOptions(projectId)); + const queryClient = useQueryClient(); const [_selection, select] = useLocalStorageState( `project:${projectId}:workspace:selection`, { defaultValue: null }, ); - const commitStackIds = getStackIdByCommitId(headInfo); - const selection = _selection ? normalizeSelection(_selection, commitStackIds) : null; + const commitStackIds = getStackIdsByCommitId(headInfo); + const branchRefsByStackId = getBranchRefsByStackId(headInfo); + const selection = + (_selection ? normalizeSelection(_selection, commitStackIds, branchRefsByStackId) : null) ?? + getDefaultSelection({ + headInfo, + changes: worktreeChanges.changes, + assignments: worktreeChanges.assignments, + }); useMonitorDraggedSourceItem({ projectId, setDraggedSourceItem }); @@ -1108,44 +1291,73 @@ const ProjectPage: FC = () => { const baseId = commonBaseCommitId(headInfo); const isUnassignedFileSelected = (path: string): boolean => - selection?._tag === "changes" && selection.stackId === null && selection.path === path; + selection?._tag === "ChangesFile" && selection.stackId === null && selection.path === path; const toggleUnassignedFileSelection = (path: string) => { - select(isUnassignedFileSelected(path) ? null : { _tag: "changes", stackId: null, path }); + select(isUnassignedFileSelected(path) ? null : { _tag: "ChangesFile", stackId: null, path }); + }; + + const isBranchSelected = (stackId: string, branchRef: string) => + selection?._tag === "Branch" && + selection.stackId === stackId && + selection.branchRef === branchRef; + const toggleBranchSelection = (stackId: string, branchName: string, branchRef: string) => { + select( + isBranchSelected(stackId, branchRef) + ? null + : { _tag: "Branch", stackId, branchName, branchRef }, + ); }; + const isCommitSelected = (stackId: string, commitId: string) => - selection?._tag === "commit" && + selection?._tag === "Commit" && selection.stackId === stackId && - selection.commitId === commitId && - selection.path === undefined; + selection.commitId === commitId; const isCommitAnyFileSelected = (stackId: string, commitId: string) => - selection?._tag === "commit" && + selection?._tag === "CommitFile" && selection.stackId === stackId && - selection.commitId === commitId && - selection.path !== undefined; + selection.commitId === commitId; const isChangeUnitFileSelected = (stackId: string, changeUnit: ChangeUnit, path: string) => { if (!selection) return false; - if (selection._tag === "commit" && changeUnit._tag === "commit") + if (selection._tag === "CommitFile" && changeUnit._tag === "Commit") return ( selection.stackId === stackId && selection.commitId === changeUnit.commitId && selection.path === path ); - if (selection._tag === "changes" && changeUnit._tag === "changes") + if (selection._tag === "ChangesFile" && changeUnit._tag === "Changes") return selection.stackId === stackId && selection.path === path; return false; }; + const toggleCommitSelection = (stackId: string, commitId: string) => { - select(isCommitSelected(stackId, commitId) ? null : { _tag: "commit", stackId, commitId }); + select(isCommitSelected(stackId, commitId) ? null : { _tag: "Commit", stackId, commitId }); + }; + const toggleCommitExpanded = async (stackId: string, commitId: string) => { + if (isCommitAnyFileSelected(stackId, commitId)) { + select({ _tag: "Commit", stackId, commitId }); + return; + } + + const commitDetails = await queryClient.ensureQueryData( + commitDetailsWithLineStatsQueryOptions({ projectId, commitId }), + ); + const firstPath = commitDetails.changes[0]?.path; + + select( + firstPath !== undefined + ? { _tag: "CommitFile", stackId, commitId, path: firstPath } + : { _tag: "Commit", stackId, commitId }, + ); }; const toggleChangeUnitFileSelection = (stackId: string, changeUnit: ChangeUnit, path: string) => { select( isChangeUnitFileSelected(stackId, changeUnit, path) - ? changeUnit._tag === "commit" - ? { _tag: "commit", stackId, commitId: changeUnit.commitId } + ? changeUnit._tag === "Commit" + ? { _tag: "Commit", stackId, commitId: changeUnit.commitId } : null - : changeUnit._tag === "commit" - ? { _tag: "commit", stackId, commitId: changeUnit.commitId, path } - : { _tag: "changes", stackId, path }, + : changeUnit._tag === "Commit" + ? { _tag: "CommitFile", stackId, commitId: changeUnit.commitId, path } + : { _tag: "ChangesFile", stackId, path }, ); }; @@ -1193,6 +1405,8 @@ const ProjectPage: FC = () => { key={stack.id} projectId={project.id} stack={stack} + isBranchSelected={isBranchSelected} + toggleBranchSelection={toggleBranchSelection} isCommitSelected={(commitId) => isCommitSelected(stackId, commitId)} isCommitAnyFileSelected={(commitId) => isCommitAnyFileSelected(stackId, commitId) @@ -1200,6 +1414,7 @@ const ProjectPage: FC = () => { isChangeUnitFileSelected={(changeUnit, path) => isChangeUnitFileSelected(stackId, changeUnit, path) } + toggleCommitExpanded={(commitId) => toggleCommitExpanded(stackId, commitId)} toggleCommitSelection={(commitId) => { toggleCommitSelection(stackId, commitId); }} diff --git a/apps/lite/ui/src/routes/project-shared.module.css b/apps/lite/ui/src/routes/project-shared.module.css index d584b8b59f..1d17cce8d6 100644 --- a/apps/lite/ui/src/routes/project-shared.module.css +++ b/apps/lite/ui/src/routes/project-shared.module.css @@ -1,3 +1,8 @@ +.button { + padding: 2px 4px; + background-color: #eee; +} + .lanes { display: flex; column-gap: 8px; @@ -43,29 +48,30 @@ .commit { display: flex; + position: relative; flex-direction: column; } .commitRow { display: flex; + align-items: baseline; } .commitDetails { padding-left: 8px; } -.commitsList { - padding-left: 0; - list-style: none; -} - .commitButton { flex-grow: 1; + padding: 2px 4px; +} + +.commitToggleExpandButton { + padding: 2px 4px; } -.fileList { - padding-left: 0; - list-style: none; +.commitMenuTrigger { + padding: 2px 4px; } .fileRow { @@ -74,6 +80,10 @@ .fileButton { flex-grow: 1; + padding: 2px 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .selected { @@ -85,9 +95,9 @@ background-color: lightgray; } -.hunks { - padding-left: 0; - list-style: none; +.highlighted { + background-color: yellow; + color: black; } .hunkHeaderRow { @@ -114,6 +124,7 @@ .editCommitMessageInput { flex-grow: 1; + padding: 2px 4px; field-sizing: content; box-sizing: content-box; min-height: calc(2lh); diff --git a/apps/lite/ui/src/routes/project-shared.tsx b/apps/lite/ui/src/routes/project-shared.tsx index b3c316ce01..743bd3e4fb 100644 --- a/apps/lite/ui/src/routes/project-shared.tsx +++ b/apps/lite/ui/src/routes/project-shared.tsx @@ -9,7 +9,15 @@ import { } from "@gitbutler/but-sdk"; import { Match } from "effect"; import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; -import { ComponentProps, FC, ReactNode, startTransition, useOptimistic, useState } from "react"; +import { + ComponentProps, + FC, + ReactNode, + startTransition, + useOptimistic, + useState, + useTransition, +} from "react"; import styles from "./project-shared.module.css"; import { commitDetailsWithLineStatsQueryOptions, @@ -163,7 +171,7 @@ export const FileDiff: FC<{ if (visibleHunks.length === 0) return
            No hunks.
            ; return ( -
              +
                {visibleHunks.map((hunk) => (
              • {renderHunk(hunk, patch)}
              • ))} @@ -177,14 +185,13 @@ export const FileDiff: FC<{ export const FileButton: FC< { change: TreeChange; - isSelected: boolean; toggleSelect: () => void; } & ComponentProps<"button"> -> = ({ change, isSelected, toggleSelect, className, ...restProps }) => ( +> = ({ change, toggleSelect, className, ...restProps }) => ( } /> - } - /> - - - setIsEditingMessage(true)} - onInsertBlank={insertBlankCommit} - parts={ContextMenu} - /> - - - - )} - - 𑁔 - - - setIsEditingMessage(true)} - onInsertBlank={insertBlankCommit} - parts={Menu} - /> - - - -
          + + + + + + + )} + + + + 𑁔 + + + + + + + + + } + /> ); }; @@ -483,7 +495,7 @@ export const CommitsList: FC<{ if (commits.length === 0) return
          No commits.
          ; return ( -
            +
              {commits.map((commit, index) => (
            • {children(commit, index)}
            • ))} diff --git a/apps/lite/ui/src/routes/root.tsx b/apps/lite/ui/src/routes/root.tsx index ad66080f5d..cd2e5496b3 100644 --- a/apps/lite/ui/src/routes/root.tsx +++ b/apps/lite/ui/src/routes/root.tsx @@ -1,9 +1,11 @@ import { QueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import sharedStyles from "./project-shared.module.css"; import { Link, Outlet, createRootRouteWithContext, useMatch } from "@tanstack/react-router"; import { FC } from "react"; import { usePreviewVisible } from "../hooks/usePreviewVisible"; import styles from "./root.module.css"; import { shortcutKeys } from "./shortcuts.ts"; +import { classes } from "./project-shared.tsx"; export const lastOpenedProjectKey = "lastProject"; @@ -90,7 +92,7 @@ const TopBar: FC = () => { {projectMatch && (