Skip to content

Commit

Permalink
add switch command, and generalize tree display (#57)
Browse files Browse the repository at this point in the history
  • Loading branch information
Opeyem1a authored Nov 1, 2024
1 parent 0eec1b4 commit cf450d1
Show file tree
Hide file tree
Showing 7 changed files with 437 additions and 214 deletions.
5 changes: 5 additions & 0 deletions src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -32,6 +33,10 @@ export const REGISTERED_COMMANDS: CommandGroup = {
component: List,
config: listConfig,
},
switch: {
component: Switch,
config: switchConfig,
},
continue: {
component: Continue,
config: continueConfig,
Expand Down
242 changes: 28 additions & 214 deletions src/commands/list.tsx
Original file line number Diff line number Diff line change
@@ -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<string, boolean> = {};
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 <SelectRootBranch />;
}

if (currentBranch.isLoading || branchNeedsRebaseRecord.isLoading) {
if (currentBranch.isLoading) {
return <Loading />;
}

const nodes = getDisplayNodes({
record: treeParentChildRecord,
branchName: rootBranchName,
});
const maxWidth = maxWidthFromDisplayNodes({ displayNodes: nodes });

return (
<Box flexDirection="column" gap={0}>
{nodes.map((node) => {
const isCurrent = currentBranch.value === node.name;
const style = styleMap[
node.prefix.length % styleMap.length
] as TextStyle;
return (
<Text key={node.name}>
<DisplayElementText
elements={[
...node.prefix,
{
symbols: `${isCurrent ? '⊗' : '◯'}${node.suffix.length ? '─' : ''}`,
},
...node.suffix,
]}
/>
<Spaces
count={maxWidth - node.width + (isCurrent ? 0 : 2)}
/>
<Text color={style.color} dimColor={style.dimColor}>
{isCurrent ? ' 👉 ' : ''}
{node.name}{' '}
{branchNeedsRebaseRecord.value?.[node.name] && (
<Text color="white">(Needs rebase)</Text>
)}
</Text>
</Text>
);
})}
</Box>
<TreeDisplayProvider>
<DoList currentBranch={currentBranch.value} />
</TreeDisplayProvider>
);
};

interface TextStyle {
color: LiteralUnion<ForegroundColorName, string>;
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;
<Box flexDirection="column" gap={0}>
{nodes.map((node) => {
return (
<Text
key={`element-${index}`}
color={style.color}
dimColor={style.dimColor}
>
{element.symbols}
</Text>
<TreeBranchDisplay
key={node.name}
node={node}
isCurrent={currentBranch === node.name}
maxWidth={maxWidth}
needsRebase={
branchNeedsRebaseRecord[node.name] ?? false
}
underline={false}
/>
);
})}
</>
</Box>
);
};

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<string, string[]>;
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: () => {
Expand Down
88 changes: 88 additions & 0 deletions src/commands/switch.tsx
Original file line number Diff line number Diff line change
@@ -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 <SelectRootBranch />;
}

if (currentBranch.isLoading) {
return <Loading />;
}

return (
<TreeDisplayProvider>
<TreeBranchSelector />
</TreeDisplayProvider>
);
};

const TreeBranchSelector = () => {
const git = useGit();
const { nodes, isLoading: isLoadingTreeDisplay } = useTreeDisplay();
const [newBranch, setNewBranch] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);

if (newBranch) {
return (
<Text>
Hopped to{' '}
<Text bold color="green">
{newBranch}
</Text>
</Text>
);
}

if (isLoadingTreeDisplay || isLoading) {
return <Loading />;
}

return (
<Box flexDirection="column">
<Text color="white" bold>
Select the branch you want to switch to
</Text>
<SelectInput
items={nodes.map((n) => ({ 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}
/>
</Box>
);
};

export const switchConfig: CommandConfig = {
description: 'Switch between branches tracked in the tree',
usage: 'switch',
key: 'switch',
aliases: ['sw'],
getProps: () => {
return {
valid: true,
props: {},
};
},
};
2 changes: 2 additions & 0 deletions src/components/select-search-input.tsx
Original file line number Diff line number Diff line change
@@ -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 = ({
Expand Down Expand Up @@ -37,6 +38,7 @@ export const SearchSelectInput = ({
<Text>
<Text italic={!search.length}>
🔎&nbsp;
<Blinker />
{search.length ? search : '(type to search)'}
</Text>
</Text>
Expand Down
Loading

0 comments on commit cf450d1

Please sign in to comment.