command palette refactor

This commit is contained in:
Kiril Videlov 2023-03-23 14:19:13 +01:00 committed by Kiril Videlov
parent f842829583
commit 5fa0b9e68e
13 changed files with 464 additions and 1024 deletions

View File

@ -1,507 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import FileIcon from './icons/FileIcon.svelte';
import CommitIcon from './icons/CommitIcon.svelte';
import BranchIcon from './icons/BranchIcon.svelte';
import ContactIcon from './icons/ContactIcon.svelte';
import ProjectIcon from './icons/ProjectIcon.svelte';
import IconPlayerPlayFilled from './icons/IconPlayerPlayFilled.svelte';
import { invoke } from '@tauri-apps/api';
import { goto } from '$app/navigation';
import { shortPath } from '$lib/paths';
import { currentProject } from '$lib/current_project';
import type { Project } from '$lib/projects';
import toast from 'svelte-french-toast';
import type { Readable } from 'svelte/store';
export let projects: Readable<Project[]>;
let showPalette = <string | false>false;
let palette: HTMLElement;
let changedFiles = {};
let commitMessage = '';
let commitMessageInput: HTMLElement;
let paletteMode = 'command';
const listFiles = (params: { projectId: string }) =>
invoke<Record<string, string>>('git_status', params);
const matchFiles = (params: { projectId: string; matchPattern: string }) =>
invoke<Array<string>>('git_match_paths', params);
const listBranches = (params: { projectId: string }) =>
invoke<Array<string>>('git_branches', params);
const switchBranch = (params: { projectId: string; branch: string }) =>
invoke<Array<string>>('git_switch_branch', params);
const commit = (params: {
projectId: string;
message: string;
files: Array<string>;
push: boolean;
}) => invoke<boolean>('git_commit', params);
const onCmdK = (event: KeyboardEvent) => {
if (event.key === 'k') {
showPalette = 'command';
setTimeout(function () {
document.getElementById('command')?.focus();
}, 100);
}
};
const onKeyD = (event: KeyboardEvent) => {
if (event.metaKey) {
switch (event.key) {
case 'k':
console.log('COMMAND');
showPalette = 'command';
setTimeout(function () {
document.getElementById('command')?.focus();
}, 100);
break;
case 'c':
showPalette = 'commit';
executeCommand('commit');
break;
case 'e':
executeCommand('contact');
break;
case 'p':
showPalette = false;
executeCommand('player');
break;
case 'r':
executeCommand('branch');
break;
}
} else {
switch (event.key) {
case 'Meta':
event.preventDefault();
break;
case 'Escape':
showPalette = false;
paletteMode = 'command';
break;
case 'ArrowDown':
if (showPalette == 'command') {
event.preventDefault();
downMenu();
}
break;
case 'ArrowUp':
if (showPalette == 'command') {
event.preventDefault();
upMenu();
}
break;
case 'Enter':
if (showPalette == 'command') {
event.preventDefault();
selectItem();
}
break;
}
}
};
function checkPaletteModal(event: Event) {
const target = event.target as HTMLElement;
if (!target) {
showPalette = false;
}
if (showPalette !== false && !palette.contains(target)) {
showPalette = false;
}
}
let activeClass = ['active', 'bg-zinc-700/50', 'text-white'];
function upMenu() {
const menu = document.getElementById('commandMenu');
if (menu) {
const items = menu.querySelectorAll('li.item');
const active = menu.querySelector('li.active');
if (active) {
const index = Array.from(items).indexOf(active);
if (index > 0) {
items[index - 1].classList.add(...activeClass);
}
active.classList.remove(...activeClass);
} else {
items[items.length - 1].classList.add(...activeClass);
}
// scroll into view
const active2 = menu.querySelector('li.active');
if (active2) {
active2.scrollIntoView({ block: 'nearest' });
}
}
}
function downMenu() {
const menu = document.getElementById('commandMenu');
if (menu) {
const items = menu.querySelectorAll('li.item');
const active = menu.querySelector('li.active');
if (active) {
const index = Array.from(items).indexOf(active);
if (index < items.length - 1) {
items[index + 1].classList.add(...activeClass);
active.classList.remove(...activeClass);
}
} else {
items[0].classList.add(...activeClass);
}
// scroll into view
const active2 = menu.querySelector('li.active');
if (active2) {
active2.scrollIntoView({ block: 'nearest' });
}
}
}
function selectItem() {
showPalette = false;
const menu = document.getElementById('commandMenu');
if (menu) {
const active = menu.querySelector('li.active');
if (active) {
const command = active.getAttribute('data-command');
const context = active.getAttribute('data-context');
if (command) {
executeCommand(command, context);
}
} else {
if ($currentProject) {
goto('/projects/' + $currentProject.id + '/search?search=' + search);
}
}
}
}
function executeCommand(command: string, context?: string | null) {
switch (command) {
case 'commit':
if ($currentProject) {
listFiles({ projectId: $currentProject.id }).then((files) => {
changedFiles = files;
});
showPalette = 'commit';
setTimeout(function () {
commitMessageInput.focus();
}, 100);
}
break;
case 'contact':
goto('/contact');
break;
case 'switch':
goto('/projects/' + context);
break;
case 'player':
if (context) {
goto(`/projects/${$currentProject?.id}/player?file=${encodeURIComponent(context)}`);
} else {
goto('/projects/' + $currentProject?.id + '/player');
}
break;
case 'branch':
showPalette = 'command';
branchSwitcher();
break;
case 'switchBranch':
if ($currentProject) {
toast.success('Not implelmented yet. :(', {
icon: '🛠️'
});
/*
this is a little dangerous right now, so lets ice it for a bit
switchBranch({ projectId: $currentProject.id, branch: context || '' }).then(() => {
});
*/
}
break;
}
}
let search = '';
$: {
searchChanged(search, showPalette == 'command');
}
let projectCommands = [
{ text: 'Commit', key: 'C', icon: CommitIcon, command: 'commit' },
{ text: 'Player', key: 'P', icon: IconPlayerPlayFilled, command: 'player' },
{ text: 'Branch', key: 'R', icon: BranchIcon, command: 'branch' }
];
let switchCommands = [];
projects.subscribe((projects) => {
switchCommands = [];
projects.forEach((p) => {
if (p.id !== $currentProject?.id) {
switchCommands.push({
text: p.title,
icon: ProjectIcon,
command: 'switch',
context: p.id
});
}
});
});
let baseCommands = [{ text: 'Contact Us', key: 'E', icon: ContactIcon, command: 'contact' }];
function commandList() {
let commands = [];
let divider = [{ type: 'divider' }];
if ($currentProject) {
commands = projectCommands.concat(divider).concat(switchCommands);
} else {
commands = switchCommands;
}
commands = commands.concat(divider).concat(baseCommands);
return commands;
}
$: menuItems = commandList();
function searchChanged(searchValue: string, showCommand: boolean) {
if (!showCommand) {
search = '';
}
if (searchValue.length == 0 && paletteMode == 'command') {
updateMenu([]);
return;
}
if ($currentProject && searchValue.length > 0) {
const searchPattern = '.*' + Array.from(searchValue).join('(.*)');
matchFiles({ projectId: $currentProject.id, matchPattern: searchPattern }).then((files) => {
let searchResults = [];
files.slice(0, 5).forEach((f) => {
searchResults.push({ text: f, icon: FileIcon, command: 'player', context: f });
});
updateMenu(searchResults);
});
}
}
function branchSwitcher() {
paletteMode = 'branch';
if ($currentProject) {
listBranches({ projectId: $currentProject.id }).then((refs) => {
let branches: any[] = [];
refs.forEach((b) => {
branches.push({ text: b, icon: BranchIcon, command: 'switchBranch', context: b });
});
menuItems = branches;
});
}
}
function updateMenu(searchResults: Array<{ text: string }>) {
if (searchResults.length == 0) {
menuItems = commandList();
} else {
menuItems = searchResults;
}
}
function doCommit() {
// get checked files
let changedFiles: Array<string> = [];
let doc = document.getElementsByClassName('file-checkbox');
Array.from(doc).forEach((c) => {
if (c.checked) {
changedFiles.push(c.dataset['file']);
}
});
if ($currentProject) {
commit({
projectId: $currentProject.id,
message: commitMessage,
files: changedFiles,
push: false
}).then((result) => {
toast.success('Commit successful!', {
icon: '🎉'
});
commitMessage = '';
showPalette = false;
});
}
}
</script>
<svelte:window on:keydown={onCmdK} />
<div on:keydown={onKeyD} on:click={checkPaletteModal}>
{#if showPalette}
<div class="relative z-10" role="dialog" aria-modal="true">
<div
class="fixed inset-0 bg-zinc-900 bg-opacity-80 transition-opacity"
in:fade={{ duration: 50 }}
out:fade={{ duration: 50 }}
/>
<div class="command-palette-modal fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
{#if showPalette == 'command'}
<div
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
bind:this={palette}
class="mx-auto max-w-2xl transform divide-y divide-zinc-500 divide-opacity-20 overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl transition-all"
style="
height: auto;
max-height: 420px;
border-width: 0.5px;
-webkit-backdrop-filter: blur(20px) saturate(190%) contrast(70%) brightness(80%);
backdrop-filter: blur(20px) saturate(190%) contrast(70%) brightness(80%);
background-color: rgba(24, 24, 27, 0.60);
border: 0.5px solid rgba(63, 63, 70, 0.50);"
>
{#if paletteMode == 'command'}
<div class="relative">
<svg
class="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-zinc-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
id="command"
type="text"
bind:value={search}
class="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-white focus:ring-0 sm:text-sm"
placeholder="Search..."
/>
</div>
{/if}
{#if paletteMode == 'branch'}
<div class="p-4 text-lg">Branch Switcher</div>
{/if}
<!-- Default state, show/hide based on command palette state. -->
<ul class="scroll-py-2 divide-y divide-zinc-500 divide-opacity-20 overflow-y-auto">
<li class="p-1">
<ul id="commandMenu" class="text-sm text-zinc-400">
{#each menuItems as item}
{#if item.type == 'divider'}
<li class="my-2 border-t border-zinc-500 border-opacity-20" />
{:else}
<!-- Active: "bg-zinc-800 text-white" -->
<li
class="item group flex cursor-default select-none items-center rounded-md px-3 py-2"
on:click={() => {
executeCommand(item.command);
}}
data-command={item.command}
data-context={item.context}
>
<!-- Active: "text-white", Not Active: "text-zinc-500" -->
<svelte:component this={item.icon} />
<span class="ml-3 flex-auto truncate">{item.text}</span>
{#if item.key}
<span
class="ml-3 flex-none rounded border-b border-black bg-zinc-800 px-1 py-1 text-xs font-semibold text-zinc-400"
>
<kbd class="font-sans"></kbd><kbd class="font-sans">{item.key}</kbd>
</span>
{/if}
</li>
{/if}
{/each}
</ul>
</li>
</ul>
</div>
{/if}
{#if showPalette == 'commit'}
<div
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
bind:this={palette}
class="mx-auto max-w-2xl transform overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl transition-all"
style="
border-width: 0.5px;
border: 0.5px solid rgba(63, 63, 70, 0.50);
-webkit-backdrop-filter: blur(20px) saturate(190%) contrast(70%) brightness(80%);
background-color: rgba(24, 24, 27, 0.6);
"
>
<div class="mb-4 w-full border-b border-zinc-700 p-4 text-lg text-white">
Commit Your Changes
</div>
<div
class="relative m-auto transform overflow-hidden p-2 text-left transition-all sm:w-full sm:max-w-sm"
>
{#if Object.entries(changedFiles).length > 0}
<div>
<div class="">
<h3 class="text-base font-semibold text-zinc-200" id="modal-title">
Commit Message
</h3>
<div class="mt-2">
<div class="mt-2">
<textarea
rows="4"
name="message"
placeholder="Description of changes"
id="commit-message"
bind:this={commitMessageInput}
bind:value={commitMessage}
class="block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:py-1.5 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
on:click={doCommit}
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>Commit Your Changes</button
>
</div>
<div class="mt-4 py-4 text-zinc-200">
<h3 class="text-base font-semibold text-zinc-200" id="modal-title">
Changed Files
</h3>
{#each Object.entries(changedFiles) as file}
<div class="flex flex-row space-x-2">
<div>
<input type="checkbox" class="file-checkbox" data-file={file[0]} checked />
</div>
<div>
{file[1]}
</div>
<div class="font-mono">
{shortPath(file[0])}
</div>
</div>
{/each}
</div>
{:else}
<div class="mx-auto text-center text-white">No changes to commit</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import { scale } from 'svelte/transition';
let dialog: HTMLDialogElement;
import { onMount } from 'svelte';
onMount(() => {
dialog.showModal();
});
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<dialog
class="rounded-lg
border border-zinc-400/40
bg-zinc-900/70 p-0 shadow-lg
backdrop-blur-xl
"
in:scale={{ duration: 150 }}
bind:this={dialog}
on:click|self={() => dialog.close()}
on:close
>
<slot />
</dialog>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import BaseDialog from './BaseDialog.svelte';
</script>
<BaseDialog on:close>
<h1>Branch</h1>
</BaseDialog>

View File

@ -0,0 +1,248 @@
<script lang="ts">
import BaseDialog from './BaseDialog.svelte';
import { currentProject } from '$lib/current_project';
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import type { Project } from '$lib/projects';
import type { CommandGroup, Command } 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';
const dispatch = createEventDispatcher();
$: scopeToProject = $currentProject ? 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: $currentProject?.id || '', matchPattern: matchFilesQuery }).then(
(files) => {
matchingFiles = files;
}
);
} else {
matchingFiles = [];
}
let projects: Readable<any> = getContext('projects');
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: 'Commit',
description: 'C',
selected: false,
action: {
component: Commit
},
visible: 'commit'.includes(userInput?.toLowerCase())
},
{
title: 'Replay History',
description: 'R',
selected: false,
action: {
component: Replay
},
visible: 'replay history'.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
dispatch('newdialog', Commit);
if (Action.isLink(selectedCommand.action)) {
goto(selectedCommand.action.href);
dispatch('close');
} else if (Action.isActionInPalette(selectedCommand.action)) {
dispatch('newdialog', selectedCommand.action.component);
}
};
let unsubscribeKeyboardHandler: () => void;
onMount(() => {
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>
<BaseDialog on:close>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex h-[640px] w-[640px] flex-col rounded text-zinc-400" on:click|stopPropagation>
<!-- Search input area -->
<div class="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="font-semibold text-zinc-300">{$currentProject?.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="w-full bg-transparent 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="flex-auto overflow-y-auto">
{#each commandGroups as group, groupIdx}
{#if group.visible}
<div class="mx-2 cursor-default select-none">
<p class="mx-2 cursor-default select-none py-2 text-sm font-semibold text-zinc-300/80">
<span>{group.name}</span>
{#if group.description}
<span class="ml-2 font-light italic text-zinc-300/60">({group.description})</span>
{/if}
</p>
<ul class="">
{#each group.commands as command, commandIdx}
{#if command.visible}
{#if Action.isLink(command.action)}
<a
on:mouseover={() => (selection = [groupIdx, commandIdx])}
on:focus={() => (selection = [groupIdx, commandIdx])}
id={`${groupIdx}-${commandIdx}`}
href={command.action.href}
class="{selection[0] === groupIdx && selection[1] === commandIdx
? 'bg-zinc-700/70'
: ''} flex cursor-default items-center rounded-lg p-2 px-2 outline-none"
>
<span class="flex-grow">{command.title}</span>
<span>{command.description}</span>
</a>
{:else if Action.isActionInPalette(command.action)}
<div
on:mouseover={() => (selection = [groupIdx, commandIdx])}
on:focus={() => (selection = [groupIdx, commandIdx])}
on:click={triggerCommand}
class="{selection[0] === groupIdx && selection[1] === commandIdx
? 'bg-zinc-700/70'
: ''} flex cursor-default items-center rounded-lg p-2 px-2 outline-none"
>
<span class="flex-grow">{command.title}</span>
<span>{command.description}</span>
</div>
{/if}
{/if}
{/each}
</ul>
</div>
{/if}
{/each}
</div>
</div>
</BaseDialog>

View File

@ -1,315 +1,81 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { currentProject } from '$lib/current_project';
import { IconCircleCancel } from '$lib/components/icons';
import type { Project } from '$lib/projects';
import tinykeys from 'tinykeys';
import type { CommandGroup, Command } from './commands';
import { Action, previousCommand, nextCommand, firstVisibleCommand } from './commands';
import type { ComponentType } from 'svelte';
import { default as RewindCommand } from './RewindCommand.svelte';
import { default as HelpCommand } from './HelpCommand.svelte';
import { invoke } from '@tauri-apps/api';
const matchFiles = (params: { projectId: string; matchPattern: string }) =>
invoke<Array<string>>('git_match_paths', params);
import { onDestroy, 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 Branch from './Branch.svelte';
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 dialog: ComponentType | undefined;
let matchFilesQuery = '';
const updateMatchFilesQuery = debounce(async () => {
matchFilesQuery = userInput;
}, 100);
function isEventTargetInputOrTextArea(target: any) {
if (target === null) return false;
let matchingFiles: Array<string> = [];
$: if (matchFilesQuery) {
matchFiles({ projectId: $currentProject?.id || '', matchPattern: matchFilesQuery }).then(
(files) => {
matchingFiles = files;
}
);
} else {
matchingFiles = [];
const targetElementName = target.tagName.toLowerCase();
return ['input', 'textarea'].includes(targetElementName);
}
$: scopeToProject = $currentProject ? true : false;
let showingCommandPalette = false;
let dialog: HTMLDialogElement;
let userInput: string;
let projects: Readable<any> = getContext('projects');
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);
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);
}
}
])
)
: bindings;
return tinykeys(target, wrappedBindings);
}
$: 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);
}
}
let componentOfTriggeredCommand: ComponentType | undefined;
let triggeredCommand: Command | undefined;
$: 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: 'Replay History',
description: 'Command',
selected: false,
action: {
component: RewindCommand
},
visible: 'replay'.includes(userInput?.toLowerCase())
},
{
title: 'Help',
description: 'Command',
selected: false,
action: {
component: HelpCommand
},
visible: 'help'.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 resetState = () => {
userInput = '';
scopeToProject = $currentProject ? true : false;
selection = [0, 0];
componentOfTriggeredCommand = undefined;
triggeredCommand = undefined;
matchingFiles = [];
};
const triggerCommand = () => {
if (
!commandGroups[selection[0]].visible ||
!commandGroups[selection[0]].commands[selection[1]].visible
) {
return;
}
if (Action.isLink(selectedCommand.action)) {
toggleCommandPalette();
goto(selectedCommand.action.href);
} else if (Action.isActionInPalette(selectedCommand.action)) {
userInput = '';
componentOfTriggeredCommand = selectedCommand.action.component;
triggeredCommand = selectedCommand;
}
};
const toggleCommandPalette = () => {
if (dialog && dialog.open) {
dialog.close();
showingCommandPalette = false;
} else {
resetState();
dialog.showModal();
showingCommandPalette = true;
}
};
let unsubscribeKeyboardHandler: () => void;
let unsubscribeKeyboardHandlerDisabledOnInput: () => void;
onMount(() => {
toggleCommandPalette(); // developmnet only
unsubscribeKeyboardHandler = tinykeys(window, {
'Meta+k': () => {
toggleCommandPalette();
},
Backspace: () => {
if (!userInput) {
if (triggeredCommand) {
// Untrigger command
componentOfTriggeredCommand = undefined;
triggeredCommand = undefined;
} else {
scopeToProject = false;
}
unsubscribeKeyboardHandler = hotkeys(
window,
{
'Meta+k': () => {
dialog === CmdK ? (dialog = undefined) : (dialog = CmdK);
}
},
Enter: () => {
triggerCommand();
false // works even when an input is focused
);
unsubscribeKeyboardHandlerDisabledOnInput = hotkeys(
window,
{
c: () => {
dialog === Commit ? (dialog = undefined) : (dialog = Commit);
},
r: () => {
dialog === Replay ? (dialog = undefined) : (dialog = Replay);
},
b: () => {
dialog === Branch ? (dialog = undefined) : (dialog = Branch);
}
},
ArrowDown: () => {
selection = nextCommand(commandGroups, selection);
},
ArrowUp: () => {
selection = previousCommand(commandGroups, selection);
},
'Control+n': () => {
selection = nextCommand(commandGroups, selection);
},
'Control+p': () => {
selection = previousCommand(commandGroups, selection);
}
});
true // disabled when an input is focused
);
});
onDestroy(() => {
unsubscribeKeyboardHandler?.();
unsubscribeKeyboardHandlerDisabledOnInput?.();
});
const onDialogClose = () => {
dialog = undefined;
};
const onNewDialog = (e: CustomEvent) => {
dialog = e.detail;
};
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<dialog
class="rounded-lg
border border-zinc-400/40
bg-zinc-900/70 p-0 backdrop-blur-xl
"
bind:this={dialog}
on:click|self={() => toggleCommandPalette()}
>
<div class="flex h-[640px] w-[640px] flex-col rounded text-zinc-400" on:click|stopPropagation>
<!-- Search input area -->
<div class="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="font-semibold text-zinc-300">{$currentProject?.title}</span>
<span class="ml-1 text-lg">/</span>
</div>
{/if}
<!-- Selected command -->
{#if scopeToProject && triggeredCommand}
<div class="mr-1 flex items-center">
<span class="font-semibold text-zinc-300">{triggeredCommand?.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="w-full bg-transparent text-zinc-300 focus:outline-none"
bind:value={userInput}
on:input={updateMatchFilesQuery}
type="text"
autofocus
placeholder={!scopeToProject
? 'Search for repositories'
: !componentOfTriggeredCommand
? 'Search for commands, files and code changes...'
: ''}
/>
</div>
<button on:click={toggleCommandPalette} class="rounded p-2 hover:bg-zinc-600">
<IconCircleCancel class="fill-zinc-400" />
</button>
</div>
</div>
<!-- Main part -->
<div class="flex-auto overflow-y-auto">
{#if componentOfTriggeredCommand}
<svelte:component this={componentOfTriggeredCommand} {userInput} />
{:else}
{#each commandGroups as group, groupIdx}
{#if group.visible}
<div class="mx-2 cursor-default select-none">
<p
class="mx-2 cursor-default select-none py-2 text-sm font-semibold text-zinc-300/80"
>
<span>{group.name}</span>
{#if group.description}
<span class="ml-2 font-light italic text-zinc-300/60">({group.description})</span>
{/if}
</p>
<ul class="">
{#each group.commands as command, commandIdx}
{#if command.visible}
{#if Action.isLink(command.action)}
<a
on:mouseover={() => (selection = [groupIdx, commandIdx])}
on:focus={() => (selection = [groupIdx, commandIdx])}
id={`${groupIdx}-${commandIdx}`}
href={command.action.href}
class="{selection[0] === groupIdx && selection[1] === commandIdx
? 'bg-zinc-700/70'
: ''} flex cursor-default items-center rounded-lg p-2 px-2 outline-none"
>
<span class="flex-grow">{command.title}</span>
<span>{command.description}</span>
</a>
{:else if Action.isActionInPalette(command.action)}
<div
on:mouseover={() => (selection = [groupIdx, commandIdx])}
on:focus={() => (selection = [groupIdx, commandIdx])}
on:click={triggerCommand}
class="{selection[0] === groupIdx && selection[1] === commandIdx
? 'bg-zinc-700/70'
: ''} flex cursor-default items-center rounded-lg p-2 px-2 outline-none"
>
<span class="flex-grow">{command.title}</span>
<span>{command.description}</span>
</div>
{/if}
{/if}
{/each}
</ul>
</div>
{/if}
{/each}
{/if}
</div>
</div>
</dialog>
{#if dialog}
<svelte:component this={dialog} on:close={onDialogClose} on:newdialog={onNewDialog} />
{/if}

View File

@ -0,0 +1,114 @@
<script lang="ts">
import BaseDialog from './BaseDialog.svelte';
import { shortPath } from '$lib/paths';
import { invoke } from '@tauri-apps/api';
import { currentProject } from '$lib/current_project';
import { onMount } from 'svelte';
import toast from 'svelte-french-toast';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
let commitMessage = '';
let changedFiles: Record<string, string> = {};
const listFiles = (params: { projectId: string }) =>
invoke<Record<string, string>>('git_status', params);
const commit = (params: {
projectId: string;
message: string;
files: Array<string>;
push: boolean;
}) => invoke<boolean>('git_commit', params);
onMount(() => {
listFiles({ projectId: $currentProject?.id || '' }).then((files) => {
changedFiles = files;
});
});
function doCommit() {
// get checked files
let changedFiles: Array<string> = [];
let doc = document.getElementsByClassName('file-checkbox');
Array.from(doc).forEach((c: any) => {
if (c.checked) {
changedFiles.push(c.dataset['file']);
}
});
if ($currentProject) {
commit({
projectId: $currentProject.id,
message: commitMessage,
files: changedFiles,
push: false
}).then((result) => {
toast.success('Commit successful!', {
icon: '🎉'
});
commitMessage = '';
dispatch('close');
});
}
}
</script>
<BaseDialog on:close>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex flex-col rounded text-zinc-400" on:click|stopPropagation>
<div class="mb-4 w-full border-b border-zinc-700 p-4 text-lg text-white">
Commit Your Changes
</div>
<div
class="relative mx-auto transform overflow-hidden p-2 text-left transition-all sm:w-full sm:max-w-sm"
>
{#if Object.entries(changedFiles).length > 0}
<div>
<div class="">
<h3 class="text-base font-semibold text-zinc-200" id="modal-title">Commit Message</h3>
<div class="mt-2">
<div class="mt-2">
<textarea
rows="4"
name="message"
placeholder="Description of changes"
id="commit-message"
bind:value={commitMessage}
class="block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:py-1.5 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
on:click={doCommit}
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>Commit Your Changes</button
>
</div>
<div class="mt-4 py-4 text-zinc-200">
<h3 class="text-base font-semibold text-zinc-200" id="modal-title">Changed Files</h3>
{#each Object.entries(changedFiles) as file}
<div class="flex flex-row space-x-2">
<div>
<input type="checkbox" class="file-checkbox" data-file={file[0]} checked />
</div>
<div>
{file[1]}
</div>
<div class="font-mono">
{shortPath(file[0])}
</div>
</div>
{/each}
</div>
{:else}
<div class="mx-auto text-center text-white">No changes to commit</div>
{/if}
</div>
</div>
</BaseDialog>

View File

@ -1,108 +0,0 @@
<script lang="ts">
import { Action, firstVisibleSubCommand, nextSubCommand, previousSubCommand } from './commands';
import tinykeys from 'tinykeys';
import { onDestroy, onMount } from 'svelte';
import { open } from '@tauri-apps/api/shell';
import { goto } from '$app/navigation';
let selection = 0;
$: selectedCommand = innerCommands[selection];
const triggerCommand = () => {
if (!innerCommands[selection].visible) {
return;
}
if (Action.isLink(selectedCommand.action)) {
console.log('triggerCommand');
// toggleCommandPalette();
// goto(selectedCommand.action.href);
open(selectedCommand.action.href);
}
};
let unsubscribeKeyboardHandler: () => void;
onMount(() => {
unsubscribeKeyboardHandler = tinykeys(window, {
ArrowDown: () => {
selection = nextSubCommand(innerCommands, selection);
},
ArrowUp: () => {
selection = previousSubCommand(innerCommands, selection);
},
'Control+n': () => {
selection = nextSubCommand(innerCommands, selection);
},
'Control+p': () => {
selection = previousSubCommand(innerCommands, selection);
},
Enter: () => {
triggerCommand();
}
});
});
onDestroy(() => {
unsubscribeKeyboardHandler?.();
});
export let userInput: string;
$: innerCommands = [
{
title: 'Open Documentation',
description: 'External link',
selected: false,
action: {
href: 'https://docs.gitbutler.com'
},
visible: 'documentation'.includes(userInput?.toLowerCase())
},
{
title: 'Join Discord Server',
description: 'External link',
selected: false,
action: {
href: 'https://discord.gg/MmFkmaJ42D'
},
visible: 'discord server'.includes(userInput?.toLowerCase())
},
{
title: 'Email Support',
description: 'External link',
selected: false,
action: {
href: 'mailto:hello@gitbutler.com'
},
visible: 'discord server'.includes(userInput?.toLowerCase())
}
];
</script>
<div class="mx-2 cursor-default select-none">
<p class="mx-2 cursor-default select-none py-2 text-sm font-semibold text-zinc-300/80">Help</p>
<ul class="">
{#each innerCommands as command, commandIdx}
{#if command.visible}
{#if Action.isLink(command.action)}
<a
target="_blank"
rel="noreferrer"
on:mouseover={() => (selection = commandIdx)}
on:focus={() => (selection = commandIdx)}
on:click={triggerCommand}
href={command.action.href}
class="{selection === commandIdx
? 'bg-zinc-700/70'
: ''} flex cursor-default items-center rounded-lg p-2 px-2 outline-none"
>
<span class="flex-grow">{command.title}</span>
<span>{command.description}</span>
</a>
{/if}
{/if}
{/each}
</ul>
</div>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import BaseDialog from './BaseDialog.svelte';
</script>
<BaseDialog on:close>
<h1>Replay Working History</h1>
</BaseDialog>

View File

@ -1,111 +0,0 @@
<script lang="ts">
import type { Command } from './commands';
import { Action, firstVisibleSubCommand, nextSubCommand, previousSubCommand } from './commands';
import tinykeys from 'tinykeys';
import { onDestroy, onMount } from 'svelte';
let unsubscribeKeyboardHandler: () => void;
onMount(() => {
unsubscribeKeyboardHandler = tinykeys(window, {
ArrowDown: () => {
selection = nextSubCommand(innerCommands, selection);
},
ArrowUp: () => {
selection = previousSubCommand(innerCommands, selection);
},
'Control+n': () => {
selection = nextSubCommand(innerCommands, selection);
},
'Control+p': () => {
selection = previousSubCommand(innerCommands, selection);
}
});
});
onDestroy(() => {
unsubscribeKeyboardHandler?.();
});
export let userInput: string;
$: innerCommands = [
{
title: 'Last 1 hour',
description: 'Command',
selected: false,
action: {
href: '/'
},
visible: 'last 1 hour'.includes(userInput?.toLowerCase())
},
{
title: 'Last 3 hours',
description: 'Command',
selected: false,
action: {
href: '/'
},
visible: 'last 3 hours'.includes(userInput?.toLowerCase())
},
{
title: 'Last 6 hours',
description: 'Command',
selected: false,
action: {
href: '/'
},
visible: 'last 6 hours'.includes(userInput?.toLowerCase())
},
{
title: 'Yesterday morning',
description: 'Command',
selected: false,
action: {
href: '/'
},
visible: 'yesterday morning'.includes(userInput?.toLowerCase())
},
{
title: 'Yesterday afternoon',
description: 'Command',
selected: false,
action: {
href: '/'
},
visible: 'yesterday afternoon'.includes(userInput?.toLowerCase())
}
] as Command[];
let selection = 0;
$: if (!innerCommands[selection]?.visible) {
selection = firstVisibleSubCommand(innerCommands);
}
</script>
<div class="mx-2 cursor-default select-none">
<p class="mx-2 cursor-default select-none py-2 text-sm font-semibold text-zinc-300/80">
Replay...
</p>
<ul class="">
{#each innerCommands as command, commandIdx}
{#if command.visible}
{#if Action.isLink(command.action)}
<a
on:mouseover={() => (selection = commandIdx)}
on:focus={() => (selection = commandIdx)}
href={command.action.href}
class="{selection === commandIdx
? 'bg-zinc-700/70'
: ''} flex cursor-default items-center rounded-lg p-2 px-2 outline-none"
>
<span class="flex-grow">{command.title}</span>
<span>{command.description}</span>
</a>
{/if}
{/if}
{/each}
</ul>
</div>

View File

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

View File

@ -1,4 +1,3 @@
import fi from 'date-fns/esm/locale/fi/index.js';
import type { ComponentType } from 'svelte';
export type ActionLink = { href: string };

View File

@ -2,3 +2,4 @@ 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';

View File

@ -7,7 +7,7 @@
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import CommandPalette from '$lib/components/CommandPalette';
export let data: LayoutData;
const { user, posthog, projects } = data;
@ -45,5 +45,5 @@
<slot />
</div>
<Toaster />
<CommandPalette {projects} />
<CommandPalette />
</div>