refactor cmdk

This commit is contained in:
Nikita Galaiko 2023-04-05 13:37:02 +02:00
parent ce590fdae4
commit a97ab9749b
13 changed files with 619 additions and 841 deletions

View File

@ -1,264 +0,0 @@
<script lang="ts">
import Modal from '../Modal.svelte';
import type { Readable } from 'svelte/store';
import type { Project } from '$lib/projects';
import type { ActionInPalette, CommandGroup } from './types';
import { onDestroy, onMount } from 'svelte';
import tinykeys from 'tinykeys';
import { goto } from '$app/navigation';
import { Action, previousCommand, nextCommand, firstVisibleCommand } from './types';
import Replay from './Replay.svelte';
import Commit from './Commit.svelte';
import { invoke } from '@tauri-apps/api';
import { createEventDispatcher } from 'svelte';
import { RewindIcon } from '$lib/components/icons';
import { GitCommitIcon } from '$lib/components/icons';
export let projects: Readable<Project[]>;
export let project: Readable<Project | undefined>;
const dispatch = createEventDispatcher<{
close: void;
newdialog: ActionInPalette<Commit | Replay>;
}>();
$: scopeToProject = $project ? true : false;
let userInput = '';
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
let timeout: ReturnType<typeof setTimeout>;
return (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
};
let matchFilesQuery = '';
const updateMatchFilesQuery = debounce(async () => {
matchFilesQuery = userInput;
}, 100);
const matchFiles = (params: { projectId: string; matchPattern: string }) =>
invoke<Array<string>>('git_match_paths', params);
let matchingFiles: Array<string> = [];
$: if (matchFilesQuery) {
matchFiles({ projectId: $project?.id || '', matchPattern: matchFilesQuery }).then((files) => {
matchingFiles = files;
});
} else {
matchingFiles = [];
}
let selection: [number, number] = [0, 0];
// if the group or the command are no longer visible, select the first visible group and first visible command
$: if (
!commandGroups[selection[0]]?.visible ||
!commandGroups[selection[0]].commands[selection[1]]?.visible
) {
selection = firstVisibleCommand(commandGroups);
}
$: selectedCommand = commandGroups[selection[0]].commands[selection[1]];
$: {
const element = document.getElementById(`${selection[0]}-${selection[1]}`);
if (element) {
// TODO: this works, but it's not standard
element.scrollIntoViewIfNeeded(false);
}
}
$: commandGroups = [
{
name: 'Go to project',
visible: !scopeToProject,
commands: $projects.map((project: Project) => {
return {
title: project.title,
description: 'Repository',
selected: false,
action: {
href: `/projects/${project.id}/`
},
visible: project.title.toLowerCase().includes(userInput?.toLowerCase())
};
})
},
{
name: 'Actions',
visible: scopeToProject,
commands: [
{
title: 'Quick commit',
description: 'C',
selected: false,
action: {
component: Commit,
props: { project }
},
icon: GitCommitIcon,
visible: 'commit'.includes(userInput?.toLowerCase())
},
{
title: 'Commit',
description: 'Shift C',
selected: false,
action: {
href: `/projects/${$project?.id}/commit`
},
icon: GitCommitIcon,
visible: 'commit'.includes(userInput?.toLowerCase())
},
{
title: 'Replay History',
description: 'R',
selected: false,
action: {
component: Replay,
props: { project }
},
icon: RewindIcon,
visible: 'replay history'.includes(userInput?.toLowerCase())
},
{
title: 'Terminal',
description: 'Cmd C',
selected: false,
action: {
href: `/projects/${$project?.id}/terminal`
},
icon: GitCommitIcon,
visible: 'commit'.includes(userInput?.toLowerCase())
}
]
},
{
name: 'Files',
visible: scopeToProject,
description: !userInput
? 'type part of a file name'
: matchingFiles.length === 0
? `no files containing '${userInput}'`
: '',
commands: matchingFiles.map((file) => {
return {
title: file,
description: 'File',
selected: false,
action: {
href: `/`
},
visible: true
};
})
}
] as CommandGroup[];
const triggerCommand = () => {
// If the selected command is a link, navigate to it, otherwise, emit a 'newdialog' event, handled in the parent component
if (Action.isLink(selectedCommand.action)) {
goto(selectedCommand.action.href);
dispatch('close');
} else if (Action.isActionInPalette(selectedCommand.action)) {
dispatch('newdialog', selectedCommand.action);
}
};
let unsubscribeKeyboardHandler: () => void;
let modal: Modal;
onMount(() => {
modal.show();
unsubscribeKeyboardHandler = tinykeys(window, {
Backspace: () => {
if (!userInput) {
scopeToProject = false;
}
},
Enter: () => {
triggerCommand();
},
ArrowDown: () => {
selection = nextCommand(commandGroups, selection);
},
ArrowUp: () => {
selection = previousCommand(commandGroups, selection);
},
'Control+n': () => {
selection = nextCommand(commandGroups, selection);
},
'Control+p': () => {
selection = previousCommand(commandGroups, selection);
}
});
});
onDestroy(() => {
unsubscribeKeyboardHandler?.();
});
</script>
<Modal on:close bind:this={modal}>
<div class="command-palette flex max-h-[360px] w-[640px] flex-col rounded text-zinc-400">
<!-- Search input area -->
<div class="search-input flex items-center border-b border-zinc-400/20 py-2">
<div class="ml-4 mr-2 flex flex-grow items-center">
<!-- Project scope -->
{#if scopeToProject}
<div class="mr-1 flex items-center">
<span class="text-lg font-semibold text-zinc-300">{$project?.title}</span>
<span class="ml-1 text-lg">/</span>
</div>
{/if}
<!-- Search input -->
<div class="mr-1 flex-grow">
<!-- svelte-ignore a11y-autofocus -->
<input
class="command-palette-input w-full bg-transparent text-lg leading-10 text-zinc-300 focus:outline-none"
bind:value={userInput}
on:input|stopPropagation={updateMatchFilesQuery}
type="text"
autofocus
placeholder={!scopeToProject
? 'Search for repositories'
: 'Search for commands, files and code changes...'}
/>
</div>
</div>
</div>
<!-- Main part -->
<div class="command-pallete-content-container flex-auto overflow-y-auto pb-2">
{#each commandGroups as group, groupIdx}
{#if group.visible}
<div class="w-full cursor-default select-none px-2">
<p class="command-palette-section-header result-section-header">
<span>{group.name}</span>
{#if group.description}
<span class="ml-2 font-light italic text-zinc-300/70">({group.description})</span>
{/if}
</p>
<ul class="quick-command-list flex flex-col text-zinc-300">
{#each group.commands as command, commandIdx}
{#if command.visible}
<li
class="{selection[0] === groupIdx && selection[1] === commandIdx
? 'bg-zinc-50/10'
: ''} quick-command-item flex w-full cursor-default"
>
<button
on:mouseover={() => (selection = [groupIdx, commandIdx])}
on:focus={() => (selection = [groupIdx, commandIdx])}
on:click={triggerCommand}
class="flex w-full gap-2"
>
<svelte:component this={command.icon} />
<span class="quick-command flex-1 text-left">{command.title}</span>
<span class="quick-command-key">{command.description}</span>
</button>
</li>
{/if}
{/each}
</ul>
</div>
{/if}
{/each}
</div>
</div>
</Modal>

View File

@ -1,102 +1,205 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
import { onMount } from 'svelte';
import tinykeys, { type KeyBindingMap } from 'tinykeys';
import CmdK from './CmdK.svelte';
import Commit from './Commit.svelte';
import Replay from './Replay.svelte';
import { goto } from '$app/navigation';
import tinykeys from 'tinykeys';
import type { Project } from '$lib/projects';
import { readable, type Readable } from 'svelte/store';
let dialog: ComponentType | undefined;
let props: Record<string, unknown> = {};
import { derived, readable, writable, type Readable } from 'svelte/store';
import { Modal } from '$lib/components';
import listAvailableCommands, { Action, type Group, type ActionComponent } from './commands';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
export let projects: Readable<Project[]>;
export let project = readable<Project | undefined>(undefined);
function isEventTargetInputOrTextArea(target: any) {
if (target === null) return false;
const input = writable('');
const scopeToProject = writable(!!$project);
project.subscribe((project) => scopeToProject.set(!!project));
const selectedGroup = writable<Group | undefined>(undefined);
const selectedComponent = writable<ActionComponent<any> | undefined>(undefined);
const targetElementName = target.tagName.toLowerCase();
return ['input', 'textarea'].includes(targetElementName);
}
const commandGroups = derived(
[projects, project, input, scopeToProject, selectedGroup],
([projects, project, input, scopeToProject, selectedGroup]) =>
selectedGroup !== undefined
? [selectedGroup]
: listAvailableCommands({ projects, project: scopeToProject ? project : undefined, input })
);
function hotkeys(target: Window | HTMLElement, bindings: KeyBindingMap, disableOnInputs = true) {
const wrappedBindings = disableOnInputs
? Object.fromEntries(
Object.entries(bindings).map(([key, handler]) => [
key,
(event: KeyboardEvent) => {
if (!isEventTargetInputOrTextArea(event.target)) {
handler(event);
}
let selection = [0, 0] as [number, number];
const selectNextCommand = () => {
if (!modal?.isOpen()) return;
Promise.resolve($commandGroups[selection[0]]).then((group) => {
if (selection[1] < group.commands.length - 1) {
selection = [selection[0], selection[1] + 1];
} else if (selection[0] < $commandGroups.length - 1) {
selection = [selection[0] + 1, 0];
}
});
};
const selectPreviousCommand = () => {
if (!modal?.isOpen()) return;
if (selection[1] > 0) {
selection = [selection[0], selection[1] - 1];
} else if (selection[0] > 0) {
Promise.resolve($commandGroups[selection[0] - 1]).then((previousGroup) => {
selection = [selection[0] - 1, previousGroup.commands.length - 1];
});
}
};
const trigger = (action: Action) => {
if (!modal?.isOpen()) return;
if (Action.isLink(action)) {
goto(action.href);
modal?.hide();
} else if (Action.isGroup(action)) {
selectedGroup.set(action);
} else if (Action.isComponent(action)) {
selectedComponent.set(action);
}
scopeToProject.set(!!$project);
};
let modal: Modal | null;
export const show = () => {
modal?.show();
};
onMount(() =>
tinykeys(window, {
Backspace: () => {
if (!modal?.isOpen()) return;
if ($selectedGroup) {
selectedGroup.set(undefined);
} else if ($selectedComponent) {
selectedComponent.set(undefined);
} else if ($input.length === 0) {
scopeToProject.set(false);
}
},
ArrowDown: selectNextCommand,
ArrowUp: selectPreviousCommand,
'Control+n': selectNextCommand,
'Control+p': selectPreviousCommand,
Enter: () => {
if (!modal?.isOpen()) return;
Promise.resolve($commandGroups[selection[0]]).then((group) =>
trigger(group.commands[selection[1]].action)
);
}
})
);
let unregisterCommandHotkeys: (() => void)[] = [];
$: {
unregisterCommandHotkeys.forEach((unregister) => unregister());
unregisterCommandHotkeys = [];
commandGroups.subscribe((groups) =>
groups.forEach((group) =>
Promise.resolve(group).then((group) =>
group.commands.forEach((command) => {
if (command.hotkey) {
unregisterCommandHotkeys.push(
tinykeys(window, {
[command.hotkey]: () => {
// only trigger if the modal is visible
modal?.isOpen() && trigger(command.action);
}
})
);
}
])
)
: bindings;
return tinykeys(target, wrappedBindings);
})
)
)
);
}
onMount(() => {
const unsubscribeKeyboardHandler = hotkeys(
window,
{
'Meta+k': () => {
dialog === CmdK
? (dialog = undefined)
: ((dialog = CmdK), (props = { projects, project }));
},
'Meta+t': () => {
if ($project) {
goto(`/projects/${$project.id}/terminal`);
}
}
},
false // works even when an input is focused
);
const unsubscribeKeyboardHandlerDisabledOnInput = hotkeys(
window,
{
c: () => {
if ($project) {
dialog === Commit ? (dialog = undefined) : ((dialog = Commit), (props = { project }));
}
},
'Shift+c': () => {
if ($project) {
goto(`/projects/${$project.id}/commit`);
}
},
r: () => {
if ($project) {
dialog === Replay ? (dialog = undefined) : ((dialog = Replay), (props = { project }));
}
},
'a i p': () => {
// my secret hotkey to go to AI Playground, nobody should know about it
if ($project) {
goto(`/projects/${$project.id}/aiplayground`);
}
}
},
true // disabled when an input is focused
);
return () => {
unsubscribeKeyboardHandler();
unsubscribeKeyboardHandlerDisabledOnInput();
};
});
const onDialogClose = () => {
dialog = undefined;
props = {};
};
const onNewDialog = (e: CustomEvent) => {
dialog = e.detail.component;
props = e.detail.props;
};
</script>
<svelte:component this={dialog} on:close={onDialogClose} on:newdialog={onNewDialog} {...props} />
<Modal bind:this={modal}>
<div class="command-palette flex w-[640px] flex-col rounded text-zinc-400">
<!-- Search input area -->
<header class="search-input flex items-center border-b border-zinc-400/20 py-2">
<div class="ml-4 mr-2 flex flex-grow items-center">
<!-- Project scope -->
{#if $scopeToProject && $project}
<div class="mr-1 flex items-center">
<span class="text-lg font-semibold text-zinc-300">{$project.title}</span>
<span class="ml-1 text-lg">/</span>
</div>
{/if}
{#if $selectedGroup}
<div class="mr-1 flex items-center">
<span class="text-lg font-semibold text-zinc-300">{$selectedGroup.title}</span>
</div>
{:else if $selectedComponent}
<div class="mr-1 flex items-center">
<span class="text-lg font-semibold text-zinc-300">{$selectedComponent.title}</span>
</div>
{:else}
<div class="mr-1 flex-grow">
<!-- svelte-ignore a11y-autofocus -->
<input
class="command-palette-input w-full bg-transparent text-lg leading-10 text-zinc-300 focus:outline-none"
bind:value={$input}
type="text"
autofocus
placeholder={!$project
? 'Search for repositories'
: 'Search for commands, files and code changes...'}
/>
</div>
{/if}
</div>
</header>
{#if $selectedComponent}
<svelte:component this={$selectedComponent.component} {...$selectedComponent.props} />
{:else}
<!-- Command list -->
<ul class="command-pallete-content-container flex-auto overflow-y-auto pb-2">
{#each $commandGroups as group, groupIdx}
{#await group then group}
<li class="w-full cursor-default select-none px-2">
<header class="command-palette-section-header result-section-header">
<span>{group.title}</span>
{#if group.description}
<span class="ml-2 font-light italic text-zinc-300/70">({group.description})</span>
{/if}
</header>
<ul class="quick-command-list flex flex-col text-zinc-300">
{#each group.commands as command, commandIdx}
<li
class="quick-command-item flex w-full cursor-default"
class:selected={selection[0] === groupIdx && selection[1] === commandIdx}
>
<button
on:mouseover={() => (selection = [groupIdx, commandIdx])}
on:focus={() => (selection = [groupIdx, commandIdx])}
on:click={() => trigger(command.action)}
class="flex w-full gap-2"
>
<svelte:component this={command.icon} />
<span class="quick-command flex-1 text-left">{command.title}</span>
{#if command.hotkey}
<span class="quick-command-key">{command.hotkey}</span>
{/if}
</button>
</li>
{/each}
</ul>
</li>
{/await}
{/each}
</ul>
{/if}
</div>
</Modal>
<style lang="postcss">
.selected {
@apply bg-zinc-50/10;
}
</style>

View File

@ -1,180 +0,0 @@
<script lang="ts">
import Modal from '../Modal.svelte';
import { collapsable } from '$lib/paths';
import * as git from '$lib/git';
import { onMount } from 'svelte';
import { success, error } from '$lib/toasts';
import { createEventDispatcher } from 'svelte';
import { readable, type Readable } from 'svelte/store';
import type { Status } from '$lib/git/statuses';
import { IconRotateClockwise } from '../icons';
import type { Project } from '$lib/projects';
const dispatch = createEventDispatcher();
let statuses = readable<Status[]>([]);
export let project: Readable<Project>;
let modal: Modal;
onMount(() => {
modal.show();
git.statuses({ projectId: $project.id ?? '' }).then((r) => (statuses = r));
});
let summary = '';
let description = '';
let isCommitting = false;
$: isCommitEnabled = summary.length > 0 && $statuses.some(({ staged }) => staged);
const reset = () => {
summary = '';
description = '';
};
const onCommit = (e: SubmitEvent) => {
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const summary = formData.get('summary') as string;
const description = formData.get('description') as string;
isCommitting = true;
git
.commit({
projectId: $project.id,
message: description.length > 0 ? `${summary}\n\n${description}` : summary,
push: false
})
.then(() => {
success('Commit created');
reset();
dispatch('close');
})
.catch(() => {
error('Failed to commit');
})
.finally(() => {
isCommitting = false;
});
};
const onGroupCheckboxClick = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
git
.stage({
projectId: $project.id,
paths: $statuses.filter(({ staged }) => !staged).map(({ path }) => path)
})
.catch(() => {
error('Failed to stage files');
});
} else {
git
.unstage({
projectId: $project.id,
paths: $statuses.filter(({ staged }) => staged).map(({ path }) => path)
})
.catch(() => {
error('Failed to unstage files');
});
}
};
</script>
<Modal on:close bind:this={modal}>
<form
class="command-palette-commit flex w-full flex-col gap-4 rounded p-4"
on:submit|preventDefault={onCommit}
>
<header class="w-full border-b border-zinc-700 text-lg font-semibold text-white">
Commit Your Changes
</header>
<fieldset class="flex flex-auto transform flex-col gap-2 overflow-auto transition-all">
{#if $statuses.length > 0}
<input
class="ring-gray-600 focus:ring-blue-100 block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-inset sm:py-1.5 sm:text-sm sm:leading-6"
type="text"
name="summary"
placeholder="Summary (required)"
bind:value={summary}
required
/>
<textarea
rows="4"
name="description"
placeholder="Description (optional)"
bind:value={description}
class="ring-gray-600 focus:ring-blue-100 block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-inset sm:py-1.5 sm:text-sm sm:leading-6"
/>
{#if isCommitting}
<div
class="flex gap-1 rounded bg-[#2563EB] py-2 px-4 disabled:cursor-not-allowed disabled:opacity-50"
>
<IconRotateClockwise class="h-5 w-5 animate-spin" />
<span>Comitting...</span>
</div>
{:else}
<button
disabled={!isCommitEnabled}
type="submit"
class="rounded bg-[#2563EB] py-2 px-4 disabled:cursor-not-allowed disabled:opacity-50"
>
Commit changes
</button>
{/if}
<ul
class="flex flex-auto flex-col overflow-auto rounded border border-card-default bg-card-active"
>
<header class="flex w-full items-center py-2 px-4">
<input
type="checkbox"
class="cursor-default disabled:opacity-50"
on:click={onGroupCheckboxClick}
checked={$statuses.every(({ staged }) => staged)}
indeterminate={$statuses.some(({ staged }) => staged) &&
$statuses.some(({ staged }) => !staged) &&
$statuses.length > 0}
disabled={isCommitting}
/>
<h1 class="m-auto flex">
<span class="w-full text-center">{$statuses.length} changed files</span>
</h1>
</header>
{#each $statuses as { path, staged }, i}
<li
class:border-b={i < $statuses.length - 1}
class="flex items-center gap-2 border-gb-700 bg-card-default"
>
<input
type="checkbox"
class="ml-4 cursor-default py-2 disabled:opacity-50"
checked={staged}
on:click|preventDefault={() => {
staged
? git.unstage({ projectId: $project.id, paths: [path] }).catch(() => {
error('Failed to unstage file');
})
: git.stage({ projectId: $project.id, paths: [path] }).catch(() => {
error('Failed to stage file');
});
}}
/>
<span
class="h-full w-full flex-auto overflow-auto py-2 pr-4 text-left font-mono disabled:opacity-50"
use:collapsable={{ value: path, separator: '/' }}
/>
</li>
{/each}
</ul>
{:else}
<div class="mx-auto text-center text-white">No changes to commit</div>
{/if}
</fieldset>
</form>
</Modal>

View File

@ -0,0 +1,175 @@
<script lang="ts">
import { collapsable } from '$lib/paths';
import * as git from '$lib/git';
import { onMount } from 'svelte';
import { success, error } from '$lib/toasts';
import { createEventDispatcher } from 'svelte';
import { readable } from 'svelte/store';
import type { Status } from '$lib/git/statuses';
import { IconRotateClockwise } from '../icons';
import type { Project } from '$lib/projects';
const dispatch = createEventDispatcher();
let statuses = readable<Status[]>([]);
export let project: Project;
onMount(() => {
git.statuses({ projectId: project.id }).then((r) => (statuses = r));
});
let summary = '';
let description = '';
let isCommitting = false;
$: isCommitEnabled = summary.length > 0 && $statuses.some(({ staged }) => staged);
const reset = () => {
summary = '';
description = '';
};
const onCommit = (e: SubmitEvent) => {
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const summary = formData.get('summary') as string;
const description = formData.get('description') as string;
isCommitting = true;
git
.commit({
projectId: project.id,
message: description.length > 0 ? `${summary}\n\n${description}` : summary,
push: false
})
.then(() => {
success('Commit created');
reset();
dispatch('close');
})
.catch(() => {
error('Failed to commit');
})
.finally(() => {
isCommitting = false;
});
};
const onGroupCheckboxClick = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
git
.stage({
projectId: project.id,
paths: $statuses.filter(({ staged }) => !staged).map(({ path }) => path)
})
.catch(() => {
error('Failed to stage files');
});
} else {
git
.unstage({
projectId: project.id,
paths: $statuses.filter(({ staged }) => staged).map(({ path }) => path)
})
.catch(() => {
error('Failed to unstage files');
});
}
};
</script>
<form
class="command-palette-commit flex w-full flex-col gap-4 rounded p-4 h-full"
on:submit|preventDefault={onCommit}
>
<header class="w-full border-b border-zinc-700 text-lg font-semibold text-white">
Commit Your Changes
</header>
<fieldset class="flex flex-auto transform flex-col gap-2 overflow-auto transition-all">
{#if $statuses.length > 0}
<input
class="ring-gray-600 focus:ring-blue-100 block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-inset sm:py-1.5 sm:text-sm sm:leading-6"
type="text"
name="summary"
placeholder="Summary (required)"
bind:value={summary}
required
/>
<textarea
rows="4"
name="description"
placeholder="Description (optional)"
bind:value={description}
class="ring-gray-600 focus:ring-blue-100 block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-inset sm:py-1.5 sm:text-sm sm:leading-6"
/>
{#if isCommitting}
<div
class="flex gap-1 rounded bg-[#2563EB] py-2 px-4 disabled:cursor-not-allowed disabled:opacity-50"
>
<IconRotateClockwise class="h-5 w-5 animate-spin" />
<span>Comitting...</span>
</div>
{:else}
<button
disabled={!isCommitEnabled}
type="submit"
class="rounded bg-[#2563EB] py-2 px-4 disabled:cursor-not-allowed disabled:opacity-50"
>
Commit changes
</button>
{/if}
<ul
class="flex flex-auto flex-col overflow-auto rounded border border-card-default bg-card-active"
>
<header class="flex w-full items-center py-2 px-4">
<input
type="checkbox"
class="cursor-default disabled:opacity-50"
on:click={onGroupCheckboxClick}
checked={$statuses.every(({ staged }) => staged)}
indeterminate={$statuses.some(({ staged }) => staged) &&
$statuses.some(({ staged }) => !staged) &&
$statuses.length > 0}
disabled={isCommitting}
/>
<h1 class="m-auto flex">
<span class="w-full text-center">{$statuses.length} changed files</span>
</h1>
</header>
{#each $statuses as { path, staged }, i}
<li
class:border-b={i < $statuses.length - 1}
class="flex items-center gap-2 border-gb-700 bg-card-default"
>
<input
type="checkbox"
class="ml-4 cursor-default py-2 disabled:opacity-50"
checked={staged}
on:click|preventDefault={() => {
staged
? git.unstage({ projectId: project.id, paths: [path] }).catch(() => {
error('Failed to unstage file');
})
: git.stage({ projectId: project.id, paths: [path] }).catch(() => {
error('Failed to stage file');
});
}}
/>
<span
class="h-full w-full flex-auto overflow-auto py-2 pr-4 text-left font-mono disabled:opacity-50"
use:collapsable={{ value: path, separator: '/' }}
/>
</li>
{/each}
</ul>
{:else}
<div class="mx-auto text-center text-white">No changes to commit</div>
{/if}
</fieldset>
</form>

View File

@ -1,124 +0,0 @@
<script lang="ts">
import Modal from '../Modal.svelte';
import { format, subDays, subWeeks, subMonths, startOfISOWeek, startOfMonth } from 'date-fns';
import { onMount } from 'svelte';
import tinykeys from 'tinykeys';
import { goto } from '$app/navigation';
import { createEventDispatcher } from 'svelte';
import type { Project } from '$lib/projects';
import type { Readable } from 'svelte/store';
export let project: Readable<Project>;
const dispatch = createEventDispatcher<{ close: void }>();
let selectionIdx = 0;
let listOptions = [
{
label: 'Earlier today',
href: `/projects/${$project.id}/player/${format(new Date(), 'yyyy-MM-dd')}/`
},
{
label: 'Yesterday',
href: `/projects/${$project.id}/player/${format(subDays(new Date(), 1), 'yyyy-MM-dd')}/`
},
{
label: 'The day before yesterday',
href: `/projects/${$project.id}/player/${format(subDays(new Date(), 2), 'yyyy-MM-dd')}/`
},
{
label: 'The beginning of last week',
href: `/projects/${$project.id}/player/${format(
startOfISOWeek(subWeeks(new Date(), 1)),
'yyyy-MM-dd'
)}/`
},
{
label: 'The beginning of last month',
href: `/projects/${$project.id}/player/${format(
startOfMonth(subMonths(new Date(), 1)),
'yyyy-MM-dd'
)}/`
}
];
const gotoDestination = () => {
goto(listOptions[selectionIdx].href);
dispatch('close');
};
let modal: Modal;
onMount(() => {
modal.show();
const unsubscribeKeyboardHandler = tinykeys(window, {
Enter: () => {
gotoDestination();
},
ArrowDown: () => {
selectionIdx = (selectionIdx + 1) % listOptions.length;
},
ArrowUp: () => {
selectionIdx = (selectionIdx - 1 + listOptions.length) % listOptions.length;
},
'Control+n': () => {
selectionIdx = (selectionIdx + 1) % listOptions.length;
},
'Control+p': () => {
selectionIdx = (selectionIdx - 1 + listOptions.length) % listOptions.length;
}
});
return () => {
unsubscribeKeyboardHandler();
};
});
</script>
<Modal on:close bind:this={modal}>
<div class="mx-2 mb-2 w-full cursor-default select-none">
<p
class="command-palette-section-header mx-2 cursor-default select-none py-2 text-sm font-semibold text-zinc-300"
>
Replay working history
</p>
<ul class="quick-command-list">
{#each listOptions as listItem, idx}
<a
on:mouseover={() => (selectionIdx = idx)}
on:focus={() => (selectionIdx = idx)}
on:click={gotoDestination}
class="{selectionIdx === idx
? 'bg-zinc-50/10'
: ''} quick-command-item flex cursor-default items-center"
href="/"
>
<span class="command-palette-icon icon-replay">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.35741 9.89998L8.89997 13.6466L8.89997 6.15333L4.35741 9.89998ZM2.93531 9.12852C2.45036 9.52851 2.45036 10.2715 2.93531 10.6714L8.76369 15.4786C9.41593 16.0166 10.4 15.5526 10.4 14.7071L10.4 5.09281C10.4 4.24735 9.41592 3.7834 8.76368 4.32136L2.93531 9.12852Z"
fill="#71717A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.1633 9.89999L15.7 13.3032L15.7 6.49683L12.1633 9.89999ZM10.7488 9.17942C10.34 9.57282 10.34 10.2272 10.7488 10.6206L15.5066 15.1987C16.1419 15.8101 17.2 15.3598 17.2 14.4782L17.2 5.32182C17.2 4.44016 16.1419 3.98992 15.5066 4.60124L10.7488 9.17942Z"
fill="#71717A"
/>
</svg>
</span>
<span class="quick-command flex-grow">{listItem.label}</span>
<span class="quick-command-key">{idx + 1}</span>
</a>
{/each}
</ul>
</div>
</Modal>

View File

@ -0,0 +1,190 @@
import QuickCommit from './QuickCommit.svelte';
import type { Project } from '$lib/projects';
import { GitCommitIcon, RewindIcon } from '../icons';
import { matchFiles } from '$lib/git';
import type { SvelteComponentTyped } from 'svelte';
import { format, startOfISOWeek, startOfMonth, subDays, subMonths, subWeeks } from 'date-fns';
type ActionLink = {
href: string;
};
interface Newable<ReturnType> {
new(...args: any[]): ReturnType;
}
type ComponentProps<T extends SvelteComponentTyped> = T extends SvelteComponentTyped<infer R>
? R
: unknown;
export type ActionComponent<Component extends SvelteComponentTyped> = {
title: string;
component: Newable<Component>;
props: ComponentProps<QuickCommit>;
};
export type Action = ActionLink | ActionComponent<QuickCommit> | Group;
export namespace Action {
export const isLink = (action: Action): action is ActionLink => 'href' in action;
export const isComponent = (action: Action): action is ActionComponent<any> =>
'component' in action;
export const isGroup = (action: Action): action is Group => 'commands' in action;
}
type Icon = Newable<GitCommitIcon> | Newable<RewindIcon>;
export type Command = {
title: string;
hotkey?: string;
action: Action;
icon?: Icon;
};
export type Group = {
title: string;
description?: string;
commands: Command[];
};
const goToProjectGroup = ({ projects, input }: { projects: Project[]; input: string }): Group => ({
title: 'Go to project',
commands: projects
.map((project, index) => ({
title: project.title,
hotkey: `${index + 1}`,
action: {
href: `/projects/${project.id}/`
}
}))
.filter(({ title }) => input.length === 0 || title.toLowerCase().includes(input.toLowerCase()))
});
const actionsGroup = ({ project, input }: { project: Project; input: string }): Group => ({
title: 'Actions',
commands: [
{
title: 'Terminal',
hotkey: 'Shift+t',
action: {
href: `/projects/${project?.id}/terminal/`
},
icon: GitCommitIcon
},
{
title: 'Quick commit',
hotkey: 'c',
action: {
title: 'Quick commit',
component: QuickCommit,
props: { project }
},
icon: GitCommitIcon
},
{
title: 'Commit',
hotkey: 'Shift+c',
action: {
href: `/projects/${project.id}/commit/`
},
icon: GitCommitIcon
},
{
title: 'Replay History',
hotkey: 'r',
action: {
title: 'Replay working history',
commands: [
{
title: 'Eralier today',
icon: RewindIcon,
hotkey: '1',
action: {
href: `/projects/${project.id}/player/${format(new Date(), 'yyyy-MM-dd')}/`
}
},
{
title: 'Yesterday',
icon: RewindIcon,
hotkey: '2',
action: {
href: `/projects/${project.id}/player/${format(
subDays(new Date(), 1),
'yyyy-MM-dd'
)}/`
}
},
{
title: 'The day before yesterday',
icon: RewindIcon,
hotkey: '3',
action: {
href: `/projects/${project.id}/player/${format(
subDays(new Date(), 2),
'yyyy-MM-dd'
)}/`
}
},
{
title: 'The beginning of last week',
icon: RewindIcon,
hotkey: '4',
action: {
href: `/projects/${project.id}/player/${format(
startOfISOWeek(subWeeks(new Date(), 1)),
'yyyy-MM-dd'
)}/`
}
},
{
title: 'The beginning of last month',
icon: RewindIcon,
hotkey: '5',
action: {
href: `/projects/${project.id}/player/${format(
startOfMonth(subMonths(new Date(), 1)),
'yyyy-MM-dd'
)}/`
}
}
]
},
icon: RewindIcon
}
].filter(({ title }) => input.length === 0 || title.toLowerCase().includes(input.toLowerCase()))
});
const fileGroup = ({
project,
input
}: {
project: Project;
input: string;
}): Group | Promise<Group> =>
input.length === 0
? {
title: 'Files',
description: 'type part of a file name',
commands: []
}
: matchFiles({ projectId: project.id, matchPattern: input }).then((files) => ({
title: 'Files',
description: files.length === 0 ? `no files containing '${input}'` : '',
commands: files.map((file) => ({
title: file,
action: {
href: '/'
}
}))
}));
export default (params: { projects: Project[]; project?: Project; input: string }) => {
const { projects, input, project } = params;
const groups = [];
!project && groups.push(goToProjectGroup({ projects, input }));
project && groups.push(actionsGroup({ project, input }));
project && groups.push(fileGroup({ project, input }));
return groups;
};

View File

@ -1,3 +0,0 @@
import { default as CommandPalette } from './CommandPalette.svelte';
export default CommandPalette;

View File

@ -1,160 +0,0 @@
import type { ComponentProps, ComponentType, SvelteComponent } from 'svelte';
import type Commit from './Commit.svelte';
import type Replay from './Replay.svelte';
export type ActionLink = { href: string };
export type ActionInPalette<Component extends SvelteComponent> = {
component: Component;
props: ComponentProps<Component>;
};
export type Action = ActionLink | ActionInPalette<Commit | Replay>;
export namespace Action {
export const isLink = (action: Action): action is ActionLink => 'href' in action;
export const isActionInPalette = (action: Action): action is ActionInPalette<any> =>
'component' in action;
}
export type Command = {
title: string;
description: string;
action: Action;
selected: boolean;
visible: boolean;
icon: ComponentType;
};
export type CommandGroup = {
name: string;
description?: string;
visible: boolean;
commands: Command[];
icon: ComponentType;
};
export const firstVisibleCommand = (commandGroups: CommandGroup[]): [number, number] => {
for (let i = 0; i < commandGroups.length; i++) {
const group = commandGroups[i];
if (group.visible) {
for (let j = 0; j < group.commands.length; j++) {
const command = group.commands[j];
if (command.visible) {
return [i, j];
}
}
}
}
return [0, 0];
};
export const lastVisibleCommand = (commandGroups: CommandGroup[]): [number, number] => {
for (let i = commandGroups.length - 1; i >= 0; i--) {
const group = commandGroups[i];
if (group.visible) {
for (let j = group.commands.length - 1; j >= 0; j--) {
const command = group.commands[j];
if (command.visible) {
return [i, j];
}
}
}
}
return [0, 0];
};
export const nextCommand = (
commandGroups: CommandGroup[],
selection: [number, number]
): [number, number] => {
// Next is in the same group
const nextVisibleCommandInGrpIndex = commandGroups[selection[0]].commands
.slice(selection[1] + 1)
.findIndex((command) => command.visible);
if (nextVisibleCommandInGrpIndex !== -1) {
// Found next visible command in the same group
return [selection[0], selection[1] + 1 + nextVisibleCommandInGrpIndex];
}
// Find next visible group
const nextVisibleGroupIndex = commandGroups
.slice(selection[0] + 1)
.findIndex((group) => group.visible);
if (nextVisibleGroupIndex !== -1) {
const firstVisibleCommandIdx = commandGroups[
selection[0] + 1 + nextVisibleGroupIndex
].commands.findIndex((command) => command.visible);
if (firstVisibleCommandIdx !== -1) {
// Found next visible command in the next group
return [selection[0] + 1 + nextVisibleGroupIndex, firstVisibleCommandIdx];
}
}
return selection;
};
export const previousCommand = (
commandGroups: CommandGroup[],
selection: [number, number]
): [number, number] => {
// Previous is in the same group
const previousVisibleCommandInGrpIndex = commandGroups[selection[0]].commands
.slice(0, selection[1])
.reverse()
.findIndex((command) => command.visible);
if (previousVisibleCommandInGrpIndex !== -1) {
// Found previous visible command in the same group
return [selection[0], selection[1] - 1 - previousVisibleCommandInGrpIndex];
}
// Find previous visible group
const previousVisibleGroupIndex = commandGroups
.slice(0, selection[0])
.reverse()
.findIndex((group) => group.visible);
if (previousVisibleGroupIndex !== -1) {
const previousVisibleCommandIndex = commandGroups[
selection[0] - 1 - previousVisibleGroupIndex
].commands
.slice()
.reverse()
.findIndex((command) => command.visible);
if (previousVisibleCommandIndex !== -1) {
// Found previous visible command in the previous group
return [selection[0] - 1 - previousVisibleGroupIndex, previousVisibleCommandIndex];
}
}
return selection;
};
export const firstVisibleSubCommand = (commands: Command[]): number => {
const firstVisibleGroup = commands.findIndex((command) => command.visible);
if (firstVisibleGroup === -1) {
return 0;
}
return firstVisibleGroup;
};
export const nextSubCommand = (commands: Command[], selection: number): number => {
const nextVisibleCommandIndex = commands
.slice(selection + 1)
.findIndex((command) => command.visible);
if (nextVisibleCommandIndex !== -1) {
return selection + 1 + nextVisibleCommandIndex;
}
return selection;
};
export const previousSubCommand = (commands: Command[], selection: number): number => {
const previousVisibleCommandIndex = commands
.slice(0, selection)
.reverse()
.findIndex((command) => command.visible);
if (previousVisibleCommandIndex !== -1) {
return selection - 1 - previousVisibleCommandIndex;
}
return selection;
};

View File

@ -1,12 +1,30 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { scale } from 'svelte/transition';
let dialog: HTMLDialogElement;
let content: HTMLDivElement | null = null;
const dispatch = createEventDispatcher<{ close: void }>();
let open = false;
export const show = () => {
open = true;
dialog.showModal();
};
export const hide = () => {
open = false;
dialog.close();
dispatch('close');
};
export const isOpen = () => open;
const handleClick = (event: MouseEvent) => {
if (content && !content.contains(event.target as Node | null)) {
hide();
}
};
</script>
@ -18,26 +36,23 @@ It does minimal styling. A close event is fired when the modal is closed.
- Usage:
```tsx
<Modal on:close>
<Modal>
your content slotted in
</Modal>
```
-->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svelte:window on:click={handleClick} />
<dialog
class="modal "
class="@apply flex w-[640px] overflow-hidden rounded-lg border-[0.5px] border-[#3F3F3f] bg-zinc-900/70 p-0 p-0 shadow-lg backdrop-blur-lg"
in:scale={{ duration: 150 }}
bind:this={dialog}
on:click|self={hide}
on:close
on:close={hide}
>
<slot />
{#if open}
<div class="flex" bind:this={content}>
<slot />
</div>
{/if}
</dialog>
<style>
.modal {
@apply flex w-[640px] overflow-hidden rounded-lg border-[0.5px] border-[#3F3F3f] bg-zinc-900/70 p-0 p-0 shadow-lg shadow-lg;
backdrop-filter: blur(10px);
}
</style>

View File

@ -2,7 +2,7 @@ export { default as BackForwardButtons } from './BackForwardButtons.svelte';
export { default as Login } from './Login.svelte';
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
export { default as CodeViewer } from './CodeViewer';
export { default as CommandPalette } from './CommandPalette';
export { default as CommandPalette } from './CommandPalette/CommandPalette.svelte';
export { default as Modal } from './Modal.svelte';
export { default as ButtonGroup } from './ButtonGroup';
export { default as Dialog } from './Dialog';

View File

@ -4,10 +4,13 @@ export { default as statuses } from './statuses';
export { default as activity } from './activity';
export const commit = (params: { projectId: string; message: string; push: boolean }) =>
invoke<boolean>('git_commit', params);
invoke<boolean>('git_commit', params);
export const stage = (params: { projectId: string; paths: Array<string> }) =>
invoke<void>('git_stage', params);
invoke<void>('git_stage', params);
export const unstage = (params: { projectId: string; paths: Array<string> }) =>
invoke<void>('git_unstage', params);
invoke<void>('git_unstage', params);
export const matchFiles = (params: { projectId: string; matchPattern: string }) =>
invoke<string[]>('git_match_paths', params);

7
src/lib/utils.ts Normal file
View File

@ -0,0 +1,7 @@
export const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
let timeout: ReturnType<typeof setTimeout>;
return (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
};

View File

@ -1,18 +1,34 @@
<script lang="ts">
import '../app.postcss';
import tinykeys from 'tinykeys';
import { Toaster } from 'svelte-french-toast';
import type { LayoutData } from './$types';
import { BackForwardButtons, Button } from '$lib/components';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
import { page } from '$app/stores';
import CommandPalette from '$lib/components/CommandPalette/CommandPalette.svelte';
import { readable } from 'svelte/store';
import { derived } from 'svelte/store';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
export let data: LayoutData;
const { user, posthog, projects } = data;
$: project = $page.params.projectId ? projects.get($page.params.projectId) : readable(undefined);
const project = derived([page, projects], ([page, projects]) =>
projects.find((project) => project.id === page.params.projectId)
);
export let commandPalette: CommandPalette;
onMount(() =>
tinykeys(window, {
'Meta+k': () => commandPalette.show(),
'Shift+c': () => $project && goto(`/projects/${$project.id}/commit/`),
'Shift+t': () => $project && goto(`/projects/${$project.id}/terminal/`),
'a i p': () => $project && goto(`/projects/${$project.id}/aiplayground/`)
})
);
user.subscribe(posthog.identify);
</script>
@ -47,5 +63,5 @@
<slot />
</div>
<Toaster />
<CommandPalette {projects} {project} />
<CommandPalette bind:this={commandPalette} {projects} {project} />
</div>