mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-23 17:43:47 +03:00
refactor cmdk
This commit is contained in:
parent
ce590fdae4
commit
a97ab9749b
@ -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>
|
|
@ -1,102 +1,205 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentType } from 'svelte';
|
import tinykeys from 'tinykeys';
|
||||||
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 type { Project } from '$lib/projects';
|
import type { Project } from '$lib/projects';
|
||||||
import { readable, type Readable } from 'svelte/store';
|
import { derived, readable, writable, type Readable } from 'svelte/store';
|
||||||
|
import { Modal } from '$lib/components';
|
||||||
let dialog: ComponentType | undefined;
|
import listAvailableCommands, { Action, type Group, type ActionComponent } from './commands';
|
||||||
let props: Record<string, unknown> = {};
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
export let projects: Readable<Project[]>;
|
export let projects: Readable<Project[]>;
|
||||||
export let project = readable<Project | undefined>(undefined);
|
export let project = readable<Project | undefined>(undefined);
|
||||||
|
|
||||||
function isEventTargetInputOrTextArea(target: any) {
|
const input = writable('');
|
||||||
if (target === null) return false;
|
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();
|
const commandGroups = derived(
|
||||||
return ['input', 'textarea'].includes(targetElementName);
|
[projects, project, input, scopeToProject, selectedGroup],
|
||||||
}
|
([projects, project, input, scopeToProject, selectedGroup]) =>
|
||||||
|
selectedGroup !== undefined
|
||||||
function hotkeys(target: Window | HTMLElement, bindings: KeyBindingMap, disableOnInputs = true) {
|
? [selectedGroup]
|
||||||
const wrappedBindings = disableOnInputs
|
: listAvailableCommands({ projects, project: scopeToProject ? project : undefined, input })
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(bindings).map(([key, handler]) => [
|
|
||||||
key,
|
|
||||||
(event: KeyboardEvent) => {
|
|
||||||
if (!isEventTargetInputOrTextArea(event.target)) {
|
|
||||||
handler(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
)
|
|
||||||
: 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,
|
let selection = [0, 0] as [number, number];
|
||||||
{
|
|
||||||
c: () => {
|
const selectNextCommand = () => {
|
||||||
if ($project) {
|
if (!modal?.isOpen()) return;
|
||||||
dialog === Commit ? (dialog = undefined) : ((dialog = Commit), (props = { project }));
|
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];
|
||||||
}
|
}
|
||||||
},
|
|
||||||
'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) => {
|
const selectPreviousCommand = () => {
|
||||||
dialog = e.detail.component;
|
if (!modal?.isOpen()) return;
|
||||||
props = e.detail.props;
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</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>
|
||||||
|
@ -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>
|
|
175
src/lib/components/CommandPalette/QuickCommit.svelte
Normal file
175
src/lib/components/CommandPalette/QuickCommit.svelte
Normal 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>
|
@ -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>
|
|
190
src/lib/components/CommandPalette/commands.ts
Normal file
190
src/lib/components/CommandPalette/commands.ts
Normal 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;
|
||||||
|
};
|
@ -1,3 +0,0 @@
|
|||||||
import { default as CommandPalette } from './CommandPalette.svelte';
|
|
||||||
|
|
||||||
export default CommandPalette;
|
|
@ -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;
|
|
||||||
};
|
|
@ -1,12 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
import { scale } from 'svelte/transition';
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
let dialog: HTMLDialogElement;
|
let dialog: HTMLDialogElement;
|
||||||
|
let content: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ close: void }>();
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
|
||||||
export const show = () => {
|
export const show = () => {
|
||||||
|
open = true;
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
};
|
};
|
||||||
export const hide = () => {
|
export const hide = () => {
|
||||||
|
open = false;
|
||||||
dialog.close();
|
dialog.close();
|
||||||
|
dispatch('close');
|
||||||
|
};
|
||||||
|
export const isOpen = () => open;
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (content && !content.contains(event.target as Node | null)) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -18,26 +36,23 @@ It does minimal styling. A close event is fired when the modal is closed.
|
|||||||
|
|
||||||
- Usage:
|
- Usage:
|
||||||
```tsx
|
```tsx
|
||||||
<Modal on:close>
|
<Modal>
|
||||||
your content slotted in
|
your content slotted in
|
||||||
</Modal>
|
</Modal>
|
||||||
```
|
```
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<svelte:window on:click={handleClick} />
|
||||||
|
|
||||||
<dialog
|
<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 }}
|
in:scale={{ duration: 150 }}
|
||||||
bind:this={dialog}
|
bind:this={dialog}
|
||||||
on:click|self={hide}
|
on:close={hide}
|
||||||
on:close
|
|
||||||
>
|
>
|
||||||
|
{#if open}
|
||||||
|
<div class="flex" bind:this={content}>
|
||||||
<slot />
|
<slot />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</dialog>
|
</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>
|
|
||||||
|
@ -2,7 +2,7 @@ export { default as BackForwardButtons } from './BackForwardButtons.svelte';
|
|||||||
export { default as Login } from './Login.svelte';
|
export { default as Login } from './Login.svelte';
|
||||||
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
|
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
|
||||||
export { default as CodeViewer } from './CodeViewer';
|
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 Modal } from './Modal.svelte';
|
||||||
export { default as ButtonGroup } from './ButtonGroup';
|
export { default as ButtonGroup } from './ButtonGroup';
|
||||||
export { default as Dialog } from './Dialog';
|
export { default as Dialog } from './Dialog';
|
||||||
|
@ -11,3 +11,6 @@ export const stage = (params: { projectId: string; paths: Array<string> }) =>
|
|||||||
|
|
||||||
export const unstage = (params: { projectId: string; paths: Array<string> }) =>
|
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
7
src/lib/utils.ts
Normal 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);
|
||||||
|
};
|
||||||
|
};
|
@ -1,18 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.postcss';
|
import '../app.postcss';
|
||||||
|
|
||||||
|
import tinykeys from 'tinykeys';
|
||||||
import { Toaster } from 'svelte-french-toast';
|
import { Toaster } from 'svelte-french-toast';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import { BackForwardButtons, Button } from '$lib/components';
|
import { BackForwardButtons, Button } from '$lib/components';
|
||||||
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
|
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import CommandPalette from '$lib/components/CommandPalette/CommandPalette.svelte';
|
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;
|
export let data: LayoutData;
|
||||||
const { user, posthog, projects } = data;
|
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);
|
user.subscribe(posthog.identify);
|
||||||
</script>
|
</script>
|
||||||
@ -47,5 +63,5 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<CommandPalette {projects} {project} />
|
<CommandPalette bind:this={commandPalette} {projects} {project} />
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user