mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-18 06:22:28 +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">
|
||||
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>
|
||||
|
@ -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">
|
||||
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>
|
||||
|
@ -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';
|
||||
|
@ -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
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">
|
||||
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>
|
||||
|
Loading…
Reference in New Issue
Block a user