add link project modal

This commit is contained in:
Nikita Galaiko 2023-05-09 16:31:27 +02:00
parent 5e20cdaeb5
commit 0b977613b1
19 changed files with 292 additions and 117 deletions

View File

@ -57,6 +57,7 @@
"eslint-plugin-svelte3": "^4.0.0",
"histoire": "^0.16.1",
"inter-ui": "^3.19.3",
"leven": "^4.0.0",
"nanoevents": "^7.0.1",
"nanoid": "^4.0.1",
"postcss": "^8.4.14",

View File

@ -42,6 +42,7 @@ specifiers:
eslint-plugin-svelte3: ^4.0.0
histoire: ^0.16.1
inter-ui: ^3.19.3
leven: ^4.0.0
nanoevents: ^7.0.1
nanoid: ^4.0.1
postcss: ^8.4.14
@ -111,6 +112,7 @@ devDependencies:
eslint-plugin-svelte3: 4.0.0_dbthnr4b2bdkhyiebwn7su3hnq
histoire: 0.16.1_vite@4.0.4
inter-ui: 3.19.3
leven: 4.0.0
nanoevents: 7.0.1
nanoid: 4.0.1
postcss: 8.4.21
@ -2580,6 +2582,11 @@ packages:
shell-quote: 1.8.1
dev: true
/leven/4.0.0:
resolution: {integrity: sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
/levn/0.3.0:
resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==}
engines: {node: '>= 0.8.0'}

View File

@ -6,7 +6,7 @@ export type Project = {
id: string;
title: string;
path: string;
api: ApiProject & { sync: boolean };
api?: ApiProject & { sync: boolean };
};
export const list = () => invoke<Project[]>('list_projects');
@ -25,36 +25,31 @@ export const del = (params: { id: string }) => invoke('delete_project', params);
const store = asyncWritable([], list);
export const Projects = () => {
return {
...store,
get: async (id: string) => {
await store.load();
const project = derived(store, (projects) => {
export const Projects = () => ({
...store,
get: async (id: string) =>
store.load().then(() => ({
...derived(store, (projects) => {
const project = projects.find((p) => p.id === id);
if (!project) throw new Error(`Project ${id} not found`);
return project;
});
return {
...project,
update: (params: { title?: string; api?: Project['api'] }) =>
update({
project: {
id,
...params
}
}).then((project) => {
store.update((projects) => projects.map((p) => (p.id === project.id ? project : p)));
return project;
}),
delete: () =>
del({ id }).then(() => store.update((projects) => projects.filter((p) => p.id !== id)))
};
},
add: (params: { path: string }) =>
add(params).then((project) => {
store.update((projects) => [...projects, project]);
return project;
})
};
};
}),
update: (params: { title?: string; api?: Project['api'] }) =>
update({
project: {
id,
...params
}
}).then((project) => {
store.update((projects) => projects.map((p) => (p.id === project.id ? project : p)));
return project;
}),
delete: () =>
del({ id }).then(() => store.update((projects) => projects.filter((p) => p.id !== id)))
})),
add: (params: { path: string }) =>
add(params).then((project) => {
store.update((projects) => [...projects, project]);
return project;
})
});

View File

@ -56,7 +56,7 @@
<style lang="postcss">
button {
@apply relative flex w-fit items-center justify-center gap-[10px] whitespace-nowrap rounded py-2 text-base font-medium leading-[20px] underline transition duration-150 ease-in-out hover:ease-in;
@apply relative flex w-fit cursor-pointer items-center justify-center gap-[10px] whitespace-nowrap rounded py-2 text-base font-medium leading-[20px] underline transition duration-150 ease-in-out hover:ease-in;
}
button:focus {

View File

@ -12,7 +12,7 @@
<Modal bind:this={modal} let:close>
<div class="modal modal-delete-project flex w-full flex-col text-zinc-300">
<header class="flex w-full justify-between gap-4 p-4">
<h2 class="text-xl ">
<h2 class="text-xl">
<slot name="title">Title</slot>
</h2>
@ -20,7 +20,7 @@
</header>
{#if $$slots.default}
<div class="p-4 text-base ">
<div class="flex-auto overflow-auto p-4 text-base">
<slot />
</div>
{/if}

View File

@ -0,0 +1,113 @@
<script lang="ts">
import leven from 'leven';
import { Button, Dialog } from '$lib/components';
import { asyncDerived } from '@square/svelte-store';
import { compareDesc, formatDistanceToNow } from 'date-fns';
import { IconBookmark, IconFolder, IconLoading } from './icons';
import { toasts, api } from '$lib';
import { onMount } from 'svelte';
type Unpromisify<T> = T extends Promise<infer U> ? U : T;
export let user: ReturnType<typeof api.users.CurrentUser>;
export let projects: ReturnType<typeof api.projects.Projects>;
export let cloud: ReturnType<typeof api.CloudApi>;
const cloudProjects = asyncDerived(user, async (user) =>
user ? await cloud.projects.list(user.access_token) : []
);
let selectedRepositoryId: string | null = null;
let project: Unpromisify<ReturnType<ReturnType<typeof api.projects.Projects>['get']>> | undefined;
export const show = async (projectId: string) => {
project = await projects.get(projectId);
dialog.show();
};
let dialog: Dialog;
let isLinking = false;
const onLinkClicked = () =>
Promise.resolve((isLinking = true))
.then(async () => {
const cloudProject = $cloudProjects.find(
(project) => project.repository_id === selectedRepositoryId
);
if (cloudProject !== undefined)
await project
?.update({ api: { ...cloudProject, sync: true } })
.then(() => toasts.success(`Project linked`));
dialog.close();
})
.catch(() => toasts.error(`Failed to link project`))
.finally(() => (isLinking = false));
</script>
<Dialog bind:this={dialog}>
<svelte:fragment slot="title">
<div class="flex items-center gap-3">
<IconBookmark />
<span class="text-xl text-zinc-300">Link to existing GitButler project </span>
</div>
</svelte:fragment>
<div class="-m-4 grid h-[296px] w-[620px] flex-auto grid-cols-2">
<div class="flex flex-col gap-2 px-4 py-6">
<h3 class="text-lg">Content</h3>
<p>
Lorem ipsum dor sit all met. Lorem ipsum dor sit all met. Lorem ipsum dor sit all met. Lorem
ipsum dor sit all met. Lorem ipsum dor sit all met. Lorem ipsum dor sit all met. Lorem ipsum
dor sit all met. Lorem ipsum dor sit all met.
</p>
</div>
<div class="flex flex-auto flex-col gap-2 overflow-y-auto bg-[#000000]/20 py-6">
<h3 class="px-4 text-lg font-semibold">Existing GitButler Projects</h3>
{#await Promise.all([cloudProjects.load(), projects.load(), project?.load()])}
<IconLoading class="m-auto animate-spin" />
{:then}
<ul class="flex flex-col gap-2 overflow-y-scroll px-4">
{#each $cloudProjects
// filter out projects that are already linked
.filter((project) => $projects?.find((p) => p?.api?.repository_id === project.repository_id) === undefined)
// sort by last updated
.sort((a, b) => compareDesc(new Date(a.updated_at), new Date(b.updated_at)))
// sort by name
.sort((a, b) => a.name.localeCompare(b.name))
// sort by name distance to linking project title
.sort( (a, b) => (!$project ? 0 : leven(a.name.toLowerCase(), $project.title.toLowerCase()) < leven(b.name.toLowerCase(), $project.title.toLowerCase()) ? -1 : 1) ) as project}
<button
class="hover:bg-card-hover flex gap-[10px] rounded bg-card-default p-2 text-left shadow-sm transition-colors duration-200 hover:cursor-pointer"
class:bg-card-active={selectedRepositoryId === project.repository_id}
on:click={() => (selectedRepositoryId = project.repository_id)}
>
<IconFolder class="text-blue-500" />
<div class="flex flex-col gap-1">
<span class="text-text-default">{project.name}</span>
<span class="text-xs text-text-subdued">
Last updated: {formatDistanceToNow(new Date(project.updated_at))} ago
</span>
</div>
</button>
{/each}
</ul>
{/await}
</div>
</div>
<svelte:fragment slot="controls" let:close>
<Button kind="outlined" on:click={close}>Not Now</Button>
<Button
disabled={selectedRepositoryId === null}
color="primary"
loading={isLinking}
on:click={onLinkClicked}
>
Link projects
</Button>
</svelte:fragment>
</Dialog>

View File

@ -1,20 +0,0 @@
<script lang="ts">
import { open } from '@tauri-apps/api/dialog';
import { toasts, api } from '$lib';
export let projects: ReturnType<typeof api.projects.Projects>;
export const show = () =>
open({ directory: true, recursive: true })
.then((selectedPath) => {
if (selectedPath === null) return;
if (Array.isArray(selectedPath) && selectedPath.length !== 1) return;
const projectPath = Array.isArray(selectedPath) ? selectedPath[0] : selectedPath;
return projects
.add({ path: projectPath })
.then(() => toasts.success('Project added successfully'));
})
.catch((e: any) => {
toasts.error(e.message);
});
</script>

View File

@ -0,0 +1,21 @@
<script lang="ts">
let className = '';
export { className as class };
</script>
<svg
class={className}
width="14"
height="16"
viewBox="0 0 14 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.4744 1.25161C12.3544 1.35116 13 2.0892 13 2.95091V15L7 12.0836L1 15V2.95091C1 2.0892 1.6448 1.35116 2.5256 1.25161C5.49855 0.916131 8.50145 0.916131 11.4744 1.25161Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View File

@ -0,0 +1,69 @@
<script lang="ts">
let className = '';
export { className as class };
</script>
<svg
class={className}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_dii_911_51117)">
<path
d="M6.75 4C6.28587 4 5.84075 4.18437 5.51256 4.51256C5.18437 4.84075 5 5.28587 5 5.75V16.25C5 17.216 5.784 18 6.75 18H19.25C19.7141 18 20.1592 17.8156 20.4874 17.4874C20.8156 17.1592 21 16.7141 21 16.25V7.75C21 7.28587 20.8156 6.84075 20.4874 6.51256C20.1592 6.18437 19.7141 6 19.25 6H12.5C12.4612 6 12.4229 5.99096 12.3882 5.97361C12.3535 5.95625 12.3233 5.93105 12.3 5.9L11.4 4.7C11.07 4.26 10.55 4 10 4H6.75Z"
fill="currentColor"
/>
</g>
<defs>
<filter
id="filter0_dii_911_51117"
x="-2"
y="-3"
width="30"
height="30"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="2.5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_911_51117" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_911_51117" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.5" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" />
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_911_51117" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.5" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.15 0" />
<feBlend
mode="normal"
in2="effect2_innerShadow_911_51117"
result="effect3_innerShadow_911_51117"
/>
</filter>
</defs>
</svg>

View File

@ -22,3 +22,5 @@ export { default as IconSearch } from './IconSearch.svelte';
export { default as IconSparkle } from './IconSparkle.svelte';
export { default as IconArrowLeft } from './IconArrowLeft.svelte';
export { default as IconArrowRight } from './IconArrowRight.svelte';
export { default as IconBookmark } from './IconBookmark.svelte';
export { default as IconFolder } from './IconFolder.svelte';

View File

@ -12,4 +12,4 @@ export { default as Statuses } from './Statuses.svelte';
export { default as Differ } from './Differ';
export { default as DeltasViewer } from './DeltasViewer.svelte';
export { default as DiffContext } from './DiffContext.svelte';
export { default as OpenNewProjectModal } from './OpenNewProjectModal.svelte';
export { default as LinkProjectDialog } from './LinkProjectDialog.svelte';

View File

@ -1 +0,0 @@
export * as projects from './projects';

View File

@ -1,33 +0,0 @@
import { asyncWritable, derived } from '@square/svelte-store';
import { api } from '$lib';
const store = asyncWritable([], api.projects.list);
export const list = () => store;
export const get = (id: string) => derived(store, (projects) => projects.find((p) => p.id === id));
export const update = async (project: { id: string; title?: string; api?: api.Project['api'] }) => {
const updated = await api.projects.update({ project });
store.update((projects) => {
const index = projects.findIndex((p) => p.id === project.id);
if (index === -1) {
return [...projects, updated];
} else {
projects[index] = updated;
return projects;
}
});
return updated;
};
export const del = async (project: { id: string }) => {
await api.projects.del(project);
store.update((projects) => projects.filter((p) => p.id !== project.id));
};
export const add = async (params: { path: string }) => {
const project = await api.projects.add(params);
store.update((projects) => [...projects, project]);
return project;
};

View File

@ -1,14 +1,16 @@
<script lang="ts">
import '../app.postcss';
import { open } from '@tauri-apps/api/dialog';
import { toasts } from '$lib';
import { Toaster } from '$lib';
import type { LayoutData } from './$types';
import {
BackForwardButtons,
Link,
LinkProjectDialog,
CommandPalette,
Breadcrumbs,
OpenNewProjectModal
Breadcrumbs
} from '$lib/components';
import { page } from '$app/stores';
import { derived } from '@square/svelte-store';
@ -17,18 +19,32 @@
import { unsubscribe } from '$lib/utils';
export let data: LayoutData;
const { user, posthog, projects, sentry, events, hotkeys } = data;
const { user, posthog, projects, sentry, events, hotkeys, cloud } = data;
const project = derived([page, projects], ([page, projects]) =>
projects?.find((project) => project.id === page.params.projectId)
);
let commandPalette: CommandPalette;
let openNewProjectModal: OpenNewProjectModal;
let linkProjectDialog: LinkProjectDialog;
onMount(() =>
unsubscribe(
events.on('openNewProjectModal', () => openNewProjectModal?.show()),
events.on('openNewProjectModal', () =>
open({ directory: true, recursive: true })
.then((selectedPath) => {
if (selectedPath === null) return;
if (Array.isArray(selectedPath) && selectedPath.length !== 1) return;
const projectPath = Array.isArray(selectedPath) ? selectedPath[0] : selectedPath;
return projects.add({ path: projectPath });
})
.then(async (project) => {
if (!project) return;
toasts.success(`Project ${project.title} created`);
linkProjectDialog?.show(project.id);
})
.catch((e: any) => toasts.error(e.message))
),
events.on('openCommandPalette', () => commandPalette?.show()),
events.on('closeCommandPalette', () => commandPalette?.close()),
events.on('goto', (path: string) => goto(path)),
@ -46,7 +62,7 @@
<div class="flex h-full max-h-full min-h-full flex-col">
<header
data-tauri-drag-region
class="flex flex-row items-center border-b border-zinc-700 pt-1 pb-1 text-zinc-400"
class="z-1 flex flex-row items-center border-b border-zinc-700 pt-1 pb-1 text-zinc-400"
>
<div class="breadkcrumb-back-forward-container ml-24">
<BackForwardButtons />
@ -74,10 +90,12 @@
<div class="flex-auto overflow-auto">
<slot />
</div>
<Toaster />
{#await Promise.all([projects.load(), project.load()]) then}
<CommandPalette bind:this={commandPalette} {projects} {project} {events} />
{/await}
</div>
<OpenNewProjectModal bind:this={openNewProjectModal} {projects} />
<LinkProjectDialog bind:this={linkProjectDialog} {cloud} {user} {projects} />
</div>

View File

@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import { unsubscribe } from '$lib/utils';
import { format } from 'date-fns';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
export let data: LayoutData;
const { hotkeys, events, user, cloud, project, head, statuses, diffs } = data;
@ -17,22 +18,15 @@
const onSearchSubmit = () => goto(`/projects/${$project?.id}/search?q=${query}`);
function projectUrl(project: Project) {
const gitUrl = project.api?.git_url;
// get host from git url
const url = new URL(gitUrl);
const host = url.origin;
const projectId = gitUrl.split('/').pop();
return `${host}/projects/${projectId}`;
}
const projectUrl = (project: Project) =>
new URL(`/projects/${project.id}`, new URL(PUBLIC_API_BASE_URL)).toString();
$: selection = $page?.route?.id?.split('/')?.[3];
let quickCommitModal: QuickCommitModal;
onMount(() =>
unsubscribe(
events.on('openQuickCommitModal', () => quickCommitModal.show()),
events.on('openQuickCommitModal', () => quickCommitModal?.show()),
hotkeys.on('C', () => events.emit('openQuickCommitModal')),
hotkeys.on('Meta+Shift+C', () => goto(`/projects/${$project.id}/commit/`)),

View File

@ -139,7 +139,7 @@
<h2 class="px-8 text-lg font-bold text-zinc-300">Recently changed files</h2>
<ul class="mr-1 flex flex-col space-y-4 overflow-y-auto pl-8 pr-5 pb-8">
{#await fileDeltas.load()}
{#await Promise.all([fileDeltas.load(), project])}
<li>
<IconLoading class="animate-spin" />
</li>

View File

@ -8,7 +8,7 @@
import { error, success } from '$lib/toasts';
import { fly } from 'svelte/transition';
import { Dialog } from '$lib/components';
import { log } from '$lib';
import { log, api } from '$lib';
import IconChevronUp from '$lib/components/icons/IconChevronUp.svelte';
import IconChevronDown from '$lib/components/icons/IconChevronDown.svelte';
import { onMount } from 'svelte';

View File

@ -2,11 +2,11 @@
import { derived } from '@square/svelte-store';
import { Button, Dialog, Login } from '$lib/components';
import type { PageData } from './$types';
import { log, toasts } from '$lib';
import { log, toasts, api } from '$lib';
import { goto } from '$app/navigation';
export let data: PageData;
const { project, user, cloud } = data;
const { projects, project, user, cloud } = data;
const repo_id = (url: string) => {
const hurl = new URL(url);
@ -65,13 +65,17 @@
try {
if (name) {
const updated = await cloud.projects.update($user.access_token, $project?.api.repository_id, {
name,
description
});
const updated = await cloud.projects.update(
$user.access_token,
$project.api.repository_id,
{
name,
description
}
);
await project.update({
title: name,
api: { ...updated, sync: $project?.api.sync || false }
api: { ...updated, sync: $project.api.sync || false }
});
}
toasts.success('Project updated');
@ -91,13 +95,14 @@
const onDeleteClicked = () =>
Promise.resolve()
.then(() => (isDeleting = true))
.then(() => project.delete())
.then(() => api.projects.del({ id: $project?.id }))
.then(() => deleteConfirmationDialog.close())
.catch((e) => {
log.error(e);
toasts.error('Failed to delete project');
})
.then(() => goto('/'))
.then(() => projects.update((projects) => projects.filter((p) => p.id !== $project?.id)))
.then(() => toasts.success('Project deleted'))
.finally(() => (isDeleting = false));
</script>

View File

@ -37,6 +37,10 @@ const config = {
overlay: {
default: '#18181B'
},
text: {
default: '#D4D4D8',
subdued: '#71717A'
},
icon: {
default: '#A1A1AA'
},