diff --git a/src/command-registry.ts b/src/command-registry.ts index 4119b5b..27289f6 100644 --- a/src/command-registry.ts +++ b/src/command-registry.ts @@ -10,6 +10,7 @@ import Hop, { hopConfig } from './commands/hop.js'; import Sync, { syncConfig } from './commands/sync.js'; import { CommandGroup } from './types.js'; import { List, listConfig } from './commands/list.js'; +import { Switch, switchConfig } from './commands/switch.js'; export const REGISTERED_COMMANDS: CommandGroup = { _group: { @@ -32,6 +33,10 @@ export const REGISTERED_COMMANDS: CommandGroup = { component: List, config: listConfig, }, + switch: { + component: Switch, + config: switchConfig, + }, continue: { component: Continue, config: continueConfig, diff --git a/src/commands/list.tsx b/src/commands/list.tsx index 4a47fff..0d0f22d 100644 --- a/src/commands/list.tsx +++ b/src/commands/list.tsx @@ -1,247 +1,61 @@ -import React, { useCallback, useMemo } from 'react'; -import { Box, Text } from 'ink'; +import React from 'react'; +import { Box } from 'ink'; import { CommandConfig } from '../types.js'; -import { ForegroundColorName } from 'chalk'; -import { LiteralUnion } from 'type-fest'; import { Loading } from '../components/loading.js'; import { SelectRootBranch } from '../components/select-root-branch.js'; -import { treeToParentChildRecord } from '../utils/tree-helpers.js'; -import { useAsyncValue } from '../hooks/use-async-value.js'; -import { useGit } from '../hooks/use-git.js'; +import { TreeBranchDisplay } from '../utils/tree-display.js'; +import { + TreeDisplayProvider, + useTreeDisplay, +} from '../contexts/tree-display.context.js'; import { useGitHelpers } from '../hooks/use-git-helpers.js'; import { useTree } from '../hooks/use-tree.js'; export const List = () => { - const git = useGit(); const { currentBranch } = useGitHelpers(); - const { get, rootBranchName, currentTree } = useTree(); - const treeParentChildRecord = useMemo( - () => treeToParentChildRecord(get()), - [] - ); - - const getBranchNeedsRebaseRecord = useCallback(async () => { - const record: Record = {}; - await Promise.all( - currentTree.map(async (_node) => { - if (!_node.parent) return null; - - record[_node.key] = await git.needsRebaseOnto({ - branch: _node.key, - ontoBranch: _node.parent, - }); - return null; - }) - ); - return record; - }, [currentTree, git.needsRebaseOnto]); - - const branchNeedsRebaseRecord = useAsyncValue({ - getValue: getBranchNeedsRebaseRecord, - }); + const { rootBranchName } = useTree(); if (!rootBranchName) { return ; } - if (currentBranch.isLoading || branchNeedsRebaseRecord.isLoading) { + if (currentBranch.isLoading) { return ; } - const nodes = getDisplayNodes({ - record: treeParentChildRecord, - branchName: rootBranchName, - }); - const maxWidth = maxWidthFromDisplayNodes({ displayNodes: nodes }); - return ( - - {nodes.map((node) => { - const isCurrent = currentBranch.value === node.name; - const style = styleMap[ - node.prefix.length % styleMap.length - ] as TextStyle; - return ( - - - - - {isCurrent ? ' 👉 ' : ''} - {node.name}{' '} - {branchNeedsRebaseRecord.value?.[node.name] && ( - (Needs rebase) - )} - - - ); - })} - + + + ); }; -interface TextStyle { - color: LiteralUnion; - dimColor?: boolean; -} - -const styleMap: TextStyle[] = [ - { color: 'cyan' }, - { color: 'blue' }, - { color: 'yellow' }, - { color: 'magentaBright' }, - { color: 'green' }, - { color: 'blueBright' }, - { color: 'yellowBright' }, - { color: 'magenta' }, - { color: 'cyanBright' }, -]; - -const DisplayElementText = ({ elements }: { elements: DisplayElement[] }) => { +const DoList = ({ currentBranch }: { currentBranch: string }) => { + const { nodes, maxWidth, branchNeedsRebaseRecord } = useTreeDisplay(); return ( - <> - {elements.map((element, index) => { - const style = styleMap[index % styleMap.length] as TextStyle; + + {nodes.map((node) => { return ( - - {element.symbols} - + ); })} - + ); }; -const Spaces = ({ count }: { count: number }) => { - return <>{Array(count).fill(' ')}; -}; - -interface DisplayElement { - symbols: string; -} - -interface DisplayNode { - prefix: DisplayElement[]; - suffix: DisplayElement[]; - name: string; - /** - * How "wide" (in increments of 2 spaces) the row will be. This helps to line up the branch names on the right - */ - width: number; -} - -const getDisplayNodes = ({ - record, - branchName, - childIndex = 0, - parentPrefix = [], - parentWidth = 0, - depth = 0, -}: { - record: Record; - branchName: string; - childIndex?: number; - parentPrefix?: DisplayElement[]; - parentWidth?: number; - depth?: number; -}): DisplayNode[] => { - const prefix = [...parentPrefix, ...prefixFromChildIndex({ childIndex })]; - const children = record[branchName] ?? []; - const suffix = suffixFromNumChildren({ - numChildren: children.length, - }); - - const widthWithoutChildren = (parentWidth ?? 0) + (childIndex ?? 0); - const widthFromChildren = children.length > 1 ? children.length - 1 : 0; - - let nodes: DisplayNode[] = []; - children.forEach((childBranch, index) => { - const childNodes = getDisplayNodes({ - record, - branchName: childBranch, - childIndex: index, - parentPrefix: prefix, - parentWidth: widthWithoutChildren, - depth: depth + 1, - }); - nodes = [...nodes, ...childNodes]; - }); - - nodes = [ - ...nodes, - { - prefix, - suffix, - name: branchName, - width: widthWithoutChildren + widthFromChildren, - }, - ]; - - return nodes; -}; - -const prefixFromChildIndex = ({ - childIndex, -}: { - childIndex: number; -}): DisplayElement[] => { - return Array(childIndex) - .fill(null) - .map(() => { - return { - symbols: '│ ', - }; - }); -}; - -const suffixFromNumChildren = ({ - numChildren, -}: { - numChildren: number; -}): DisplayElement[] => { - if (numChildren < 1) return [] as DisplayElement[]; - - const length = numChildren - 1; - return Array(length) - .fill(null) - .map((_, index) => { - if (index === length - 1) return { symbols: '┘' }; - if (index === 0) return { symbols: '┴─' }; - return { symbols: '┴─' }; - }); -}; - -const maxWidthFromDisplayNodes = ({ - displayNodes, -}: { - displayNodes: DisplayNode[]; -}) => { - let maxWidth = 0; - - displayNodes.forEach((node) => { - maxWidth = Math.max(node.width, maxWidth); - }); - - return maxWidth; -}; - export const listConfig: CommandConfig = { description: 'Print out a tree representation of the current branch structure', - usage: 'list"', + usage: 'list', key: 'list', aliases: ['ls'], getProps: () => { diff --git a/src/commands/switch.tsx b/src/commands/switch.tsx new file mode 100644 index 0000000..acd00e4 --- /dev/null +++ b/src/commands/switch.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import SelectInput from 'ink-select-input'; +import { Box, Text } from 'ink'; +import { CommandConfig } from '../types.js'; +import { Loading } from '../components/loading.js'; +import { SelectRootBranch } from '../components/select-root-branch.js'; +import { TreeDisplayItemComponent } from '../components/tree-display-item-component.js'; +import { + TreeDisplayProvider, + useTreeDisplay, +} from '../contexts/tree-display.context.js'; +import { useGit } from '../hooks/use-git.js'; +import { useGitHelpers } from '../hooks/use-git-helpers.js'; +import { useTree } from '../hooks/use-tree.js'; + +export const Switch = () => { + const { currentBranch } = useGitHelpers(); + const { rootBranchName } = useTree(); + + if (!rootBranchName) { + return ; + } + + if (currentBranch.isLoading) { + return ; + } + + return ( + + + + ); +}; + +const TreeBranchSelector = () => { + const git = useGit(); + const { nodes, isLoading: isLoadingTreeDisplay } = useTreeDisplay(); + const [newBranch, setNewBranch] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + + if (newBranch) { + return ( + + Hopped to{' '} + + {newBranch} + + + ); + } + + if (isLoadingTreeDisplay || isLoading) { + return ; + } + + return ( + + + Select the branch you want to switch to + + ({ label: n.name, value: n.name }))} + itemComponent={TreeDisplayItemComponent} + onSelect={(item) => { + setIsLoading(true); + void git.checkout(item.value).then(() => { + setNewBranch(item.value); + setIsLoading(false); + }); + }} + limit={nodes.length} + /> + + ); +}; + +export const switchConfig: CommandConfig = { + description: 'Switch between branches tracked in the tree', + usage: 'switch', + key: 'switch', + aliases: ['sw'], + getProps: () => { + return { + valid: true, + props: {}, + }; + }, +}; diff --git a/src/components/select-search-input.tsx b/src/components/select-search-input.tsx index f8d0645..185467c 100644 --- a/src/components/select-search-input.tsx +++ b/src/components/select-search-input.tsx @@ -1,6 +1,7 @@ import GumptionItemComponent from './gumption-item-component.js'; import React, { useMemo, useState } from 'react'; import SelectInput from 'ink-select-input'; +import { Blinker } from './blinker.js'; import { Box, Text, useInput } from 'ink'; export const SearchSelectInput = ({ @@ -37,6 +38,7 @@ export const SearchSelectInput = ({ 🔎  + {search.length ? search : '(type to search)'} diff --git a/src/components/tree-display-item-component.tsx b/src/components/tree-display-item-component.tsx new file mode 100644 index 0000000..5336ef9 --- /dev/null +++ b/src/components/tree-display-item-component.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { type ItemProps } from 'ink-select-input'; +import { TreeBranchDisplay } from '../utils/tree-display.js'; +import { useGitHelpers } from '../hooks/use-git-helpers.js'; +import { useTreeDisplay } from '../contexts/tree-display.context.js'; + +export const TreeDisplayItemComponent = ({ + isSelected = false, + label, +}: ItemProps) => { + const { currentBranch } = useGitHelpers(); + const { nodes, maxWidth } = useTreeDisplay(); + + const node = nodes.find((n) => n.name === label); + + if (!node || currentBranch.isLoading) { + return null; + } + + return ( + + ); +}; diff --git a/src/contexts/tree-display.context.tsx b/src/contexts/tree-display.context.tsx new file mode 100644 index 0000000..c516eba --- /dev/null +++ b/src/contexts/tree-display.context.tsx @@ -0,0 +1,95 @@ +import React, { + ReactNode, + createContext, + useCallback, + useContext, + useMemo, +} from 'react'; +import { + DisplayNode, + getDisplayNodes, + maxWidthFromDisplayNodes, +} from '../utils/tree-display.js'; +import { treeToParentChildRecord } from '../utils/tree-helpers.js'; +import { useAsyncValue } from '../hooks/use-async-value.js'; +import { useGit } from '../hooks/use-git.js'; +import { useTree } from '../hooks/use-tree.js'; + +interface TreeDisplayContextType { + maxWidth: number; + nodes: DisplayNode[]; + branchNeedsRebaseRecord: Record; + isLoading: boolean; +} + +const TreeDisplayContext = createContext({ + maxWidth: 0, + nodes: [], + branchNeedsRebaseRecord: {}, + isLoading: true, +}); + +export const TreeDisplayProvider = ({ children }: { children: ReactNode }) => { + const git = useGit(); + const { rootBranchName, currentTree } = useTree(); + const treeParentChildRecord = useMemo( + () => treeToParentChildRecord(currentTree), + [currentTree] + ); + + const getBranchNeedsRebaseRecord = useCallback(async () => { + const record: Record = {}; + await Promise.all( + currentTree.map(async (_node) => { + if (!_node.parent) return null; + + record[_node.key] = await git.needsRebaseOnto({ + branch: _node.key, + ontoBranch: _node.parent, + }); + return null; + }) + ); + return record; + }, [currentTree, git.needsRebaseOnto]); + + const branchNeedsRebaseRecordResult = useAsyncValue({ + getValue: getBranchNeedsRebaseRecord, + }); + + const branchNeedsRebaseRecord = useMemo(() => { + if (branchNeedsRebaseRecordResult.isLoading) + return {} as Record; + + return branchNeedsRebaseRecordResult.value; + }, [branchNeedsRebaseRecordResult]); + + const isLoading = useMemo(() => { + return branchNeedsRebaseRecordResult.isLoading; + }, [branchNeedsRebaseRecordResult]); + + const nodes: DisplayNode[] = rootBranchName + ? getDisplayNodes({ + record: treeParentChildRecord, + branchName: rootBranchName, + }) + : []; + const maxWidth = maxWidthFromDisplayNodes({ displayNodes: nodes }); + + return ( + + {children} + + ); +}; + +export const useTreeDisplay = () => { + return useContext(TreeDisplayContext); +}; diff --git a/src/utils/tree-display.tsx b/src/utils/tree-display.tsx new file mode 100644 index 0000000..104f188 --- /dev/null +++ b/src/utils/tree-display.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { ForegroundColorName } from 'chalk'; +import { LiteralUnion } from 'type-fest'; +import { Text } from 'ink'; + +export const TreeBranchDisplay = ({ + node, + isCurrent, + maxWidth, + needsRebase, + underline, +}: { + node: DisplayNode; + isCurrent: boolean; + maxWidth: number; + needsRebase: boolean; + underline: boolean; +}) => { + const style = styleMap[node.prefix.length % styleMap.length] as TextStyle; + + return ( + + + + + {isCurrent ? ' 👉 ' : ''} + {node.name}{' '} + {needsRebase && (Needs rebase)} + + + ); +}; + +interface DisplayElement { + symbols: string; +} + +export interface DisplayNode { + prefix: DisplayElement[]; + suffix: DisplayElement[]; + name: string; + /** + * How "wide" (in increments of 2 spaces) the row will be. This helps to line up the branch names on the right + */ + width: number; +} + +interface TextStyle { + color: LiteralUnion; + dimColor?: boolean; +} + +const styleMap: TextStyle[] = [ + { color: 'cyan' }, + { color: 'blue' }, + { color: 'yellow' }, + { color: 'magentaBright' }, + { color: 'green' }, + { color: 'blueBright' }, + { color: 'yellowBright' }, + { color: 'magenta' }, + { color: 'cyanBright' }, +]; + +const DisplayElementText = ({ elements }: { elements: DisplayElement[] }) => { + return ( + <> + {elements.map((element, index) => { + const style = styleMap[index % styleMap.length] as TextStyle; + return ( + + {element.symbols} + + ); + })} + + ); +}; + +const Spaces = ({ count }: { count: number }) => { + return <>{Array(count).fill(' ')}; +}; + +export const getDisplayNodes = ({ + record, + branchName, + childIndex = 0, + parentPrefix = [], + parentWidth = 0, + depth = 0, +}: { + record: Record; + branchName: string; + childIndex?: number; + parentPrefix?: DisplayElement[]; + parentWidth?: number; + depth?: number; +}): DisplayNode[] => { + const prefix = [...parentPrefix, ...prefixFromChildIndex({ childIndex })]; + const children = record[branchName] ?? []; + const suffix = suffixFromNumChildren({ + numChildren: children.length, + }); + + const widthWithoutChildren = (parentWidth ?? 0) + (childIndex ?? 0); + const widthFromChildren = children.length > 1 ? children.length - 1 : 0; + + let nodes: DisplayNode[] = []; + children.forEach((childBranch, index) => { + const childNodes = getDisplayNodes({ + record, + branchName: childBranch, + childIndex: index, + parentPrefix: prefix, + parentWidth: widthWithoutChildren, + depth: depth + 1, + }); + nodes = [...nodes, ...childNodes]; + }); + + nodes = [ + ...nodes, + { + prefix, + suffix, + name: branchName, + width: widthWithoutChildren + widthFromChildren, + }, + ]; + + return nodes; +}; + +const prefixFromChildIndex = ({ + childIndex, +}: { + childIndex: number; +}): DisplayElement[] => { + return Array(childIndex) + .fill(null) + .map(() => { + return { + symbols: '│ ', + }; + }); +}; + +const suffixFromNumChildren = ({ + numChildren, +}: { + numChildren: number; +}): DisplayElement[] => { + if (numChildren < 1) return [] as DisplayElement[]; + + const length = numChildren - 1; + return Array(length) + .fill(null) + .map((_, index) => { + if (index === length - 1) return { symbols: '┘' }; + if (index === 0) return { symbols: '┴─' }; + return { symbols: '┴─' }; + }); +}; + +export const maxWidthFromDisplayNodes = ({ + displayNodes, +}: { + displayNodes: DisplayNode[]; +}) => { + let maxWidth = 0; + + displayNodes.forEach((node) => { + maxWidth = Math.max(node.width, maxWidth); + }); + + return maxWidth; +};