Merge pull request #4873 from gitbutlerapp/issues

Issues
This commit is contained in:
Caleb Owens 2024-09-11 10:31:05 +02:00 committed by GitHub
commit 5a3d788927
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 602 additions and 1 deletions

View File

@ -20,3 +20,8 @@ export function featureBranchStacking(): Persisted<boolean> {
const key = 'branchStacking'; const key = 'branchStacking';
return persisted(false, key); return persisted(false, key);
} }
export function featureTopics(): Persisted<boolean> {
const key = 'feature--topics';
return persisted(false, key);
}

View File

@ -36,6 +36,10 @@ export class AzureDevOps implements GitHost {
return undefined; return undefined;
} }
issueService() {
return undefined;
}
prService() { prService() {
return undefined; return undefined;
} }

View File

@ -40,6 +40,10 @@ export class BitBucket implements GitHost {
return undefined; return undefined;
} }
issueService() {
return undefined;
}
prService() { prService() {
return undefined; return undefined;
} }

View File

@ -2,6 +2,7 @@ import { GitHubBranch } from './githubBranch';
import { GitHubChecksMonitor } from './githubChecksMonitor'; import { GitHubChecksMonitor } from './githubChecksMonitor';
import { GitHubListingService } from './githubListingService'; import { GitHubListingService } from './githubListingService';
import { GitHubPrService } from './githubPrService'; import { GitHubPrService } from './githubPrService';
import { GitHubIssueService } from '$lib/gitHost/github/issueService';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import type { ProjectMetrics } from '$lib/metrics/projectMetrics'; import type { ProjectMetrics } from '$lib/metrics/projectMetrics';
import type { Persisted } from '$lib/persisted/persisted'; import type { Persisted } from '$lib/persisted/persisted';
@ -64,6 +65,13 @@ export class GitHub implements GitHost {
); );
} }
issueService() {
if (!this.octokit) {
return;
}
return new GitHubIssueService(this.octokit, this.repo);
}
checksMonitor(sourceBranch: string) { checksMonitor(sourceBranch: string) {
if (!this.octokit) { if (!this.octokit) {
return; return;

View File

@ -0,0 +1,29 @@
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
import type { RepoInfo } from '$lib/url/gitUrl';
import type { Octokit } from '@octokit/rest';
export class GitHubIssueService implements GitHostIssueService {
constructor(
private octokit: Octokit,
private repository: RepoInfo
) {}
async create(title: string, body: string, labels: string[]): Promise<void> {
await this.octokit.rest.issues.create({
repo: this.repository.name,
owner: this.repository.owner,
title,
body,
labels
});
}
async listLabels(): Promise<string[]> {
return (
await this.octokit.rest.issues.listLabelsForRepo({
repo: this.repository.name,
owner: this.repository.owner
})
).data.map((label) => label.name);
}
}

View File

@ -41,6 +41,10 @@ export class GitLab implements GitHost {
return undefined; return undefined;
} }
issueService() {
return undefined;
}
prService() { prService() {
return undefined; return undefined;
} }

View File

@ -1,4 +1,5 @@
import { buildContextStore } from '$lib/utils/context'; import { buildContextStore } from '$lib/utils/context';
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
import type { GitHostBranch } from './gitHostBranch'; import type { GitHostBranch } from './gitHostBranch';
import type { GitHostChecksMonitor } from './gitHostChecksMonitor'; import type { GitHostChecksMonitor } from './gitHostChecksMonitor';
import type { GitHostListingService } from './gitHostListingService'; import type { GitHostListingService } from './gitHostListingService';
@ -8,6 +9,8 @@ export interface GitHost {
// Lists PRs for the repo. // Lists PRs for the repo.
listService(): GitHostListingService | undefined; listService(): GitHostListingService | undefined;
issueService(): GitHostIssueService | undefined;
// Detailed information about a specific PR. // Detailed information about a specific PR.
prService(): GitHostPrService | undefined; prService(): GitHostPrService | undefined;

View File

@ -0,0 +1,4 @@
export interface GitHostIssueService {
create(title: string, body: string, labels: string[]): Promise<void>;
listLabels(): Promise<string[]>;
}

View File

@ -6,8 +6,10 @@
import WorkspaceButton from './WorkspaceButton.svelte'; import WorkspaceButton from './WorkspaceButton.svelte';
import Resizer from '../shared/Resizer.svelte'; import Resizer from '../shared/Resizer.svelte';
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import { featureTopics } from '$lib/config/uiFeatureFlags';
import { ModeService } from '$lib/modes/service'; import { ModeService } from '$lib/modes/service';
import EditButton from '$lib/navigation/EditButton.svelte'; import EditButton from '$lib/navigation/EditButton.svelte';
import TopicsButton from '$lib/navigation/TopicsButton.svelte';
import { persisted } from '$lib/persisted/persisted'; import { persisted } from '$lib/persisted/persisted';
import { platformName } from '$lib/platform/platform'; import { platformName } from '$lib/platform/platform';
import { SETTINGS, type Settings } from '$lib/settings/userSettings'; import { SETTINGS, type Settings } from '$lib/settings/userSettings';
@ -43,6 +45,8 @@
const modeService = getContext(ModeService); const modeService = getContext(ModeService);
const mode = modeService.mode; const mode = modeService.mode;
const topicsEnabled = featureTopics();
</script> </script>
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
@ -120,6 +124,10 @@
{:else if $mode?.type === 'Edit'} {:else if $mode?.type === 'Edit'}
<EditButton href={`/${project.id}/edit`} isNavCollapsed={$isNavCollapsed} /> <EditButton href={`/${project.id}/edit`} isNavCollapsed={$isNavCollapsed} />
{/if} {/if}
{#if $topicsEnabled}
<TopicsButton href={`/${project.id}/topics`} isNavCollapsed={$isNavCollapsed} />
{/if}
</div> </div>
</div> </div>

View File

@ -0,0 +1,34 @@
<script lang="ts">
import DomainButton from '$lib/navigation/DomainButton.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
interface Props {
href: string;
isNavCollapsed: boolean;
}
const { href, isNavCollapsed }: Props = $props();
const label = 'Topics';
</script>
<DomainButton
isSelected={$page.url.pathname === href}
{isNavCollapsed}
tooltipLabel={label}
onmousedown={async () => await goto(href)}
>
<img class="icon" src="/images/domain-icons/working-branches.svg" alt="" />
{#if !isNavCollapsed}
<span class="text-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span>
{/if}
</DomainButton>
<style lang="postcss">
.icon {
border-radius: var(--radius-s);
height: 20px;
width: 20px;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,149 @@
<script lang="ts">
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import TextArea from '$lib/shared/TextArea.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import { TopicService, type Topic } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import { createKeybind } from '$lib/utils/hotkeys';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
interface Props {
registerKeypress?: boolean;
topic?: Topic;
}
const { registerKeypress = false, topic }: Props = $props();
const gitHost = getGitHost();
const issueService = $derived($gitHost?.issueService());
const topicService = getContext(TopicService);
let modal = $state<Modal>();
let chooseLabelModal = $state<Modal>();
let availables = $state<string[]>([]);
let labels = $state<string[]>([]);
let title = $state(topic?.title || '');
let body = $state(topic?.body || '');
$effect(() => {
issueService?.listLabels().then((labels) => {
availables = labels;
});
});
let submitProgress = $state<'inert' | 'loading' | 'complete'>('inert');
async function submit() {
submitProgress = 'loading';
issueService?.create(title, body, labels);
if (topic) {
const updatedTopic = { ...topic, hasIssue: true };
topicService.update(updatedTopic);
} else {
topicService.create(title, body, true);
}
submitProgress = 'complete';
modal?.close();
}
export function open() {
title = topic?.title || '';
body = topic?.body || '';
labels = [];
submitProgress = 'inert';
modal?.show();
}
let handleKeyDown = $state(() => {});
$effect(() => {
if (registerKeypress) {
handleKeyDown = createKeybind({
'$mod+i': open
});
} else {
handleKeyDown = () => {};
}
});
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if issueService}
<Modal bind:this={modal}>
<h2 class="text-18 text-bold">Create an issue</h2>
<div class="input">
<p class="text-14 label">Title</p>
<TextBox bind:value={title} />
</div>
<div class="input">
<p class="text-14 label">Body</p>
<TextArea bind:value={body} />
</div>
<div class="labels">
{#each labels as label}
<Button onclick={() => (labels = labels.filter((l) => l !== label))} size="tag"
>{label}</Button
>
{/each}
<Modal bind:this={chooseLabelModal} width="small">
<div class="availables">
{#each availables.filter((label) => !labels.includes(label)) as label}
<Button
onclick={() => {
labels.push(label);
chooseLabelModal?.close();
}}
size="tag">{label}</Button
>
{/each}
</div>
</Modal>
<Button icon="plus-small" size="tag" onclick={() => chooseLabelModal?.show()}
>Add Label</Button
>
</div>
{#snippet controls()}
<Button onclick={() => modal?.close()}>Cancel</Button>
<Button kind="solid" style="pop" onclick={submit} loading={submitProgress === 'loading'}
>Submit</Button
>
{/snippet}
</Modal>
{/if}
<style lang="postcss">
.input {
margin-top: 8px;
}
.label {
margin-bottom: 4px;
}
.labels {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.availables {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@ -0,0 +1,85 @@
<script lang="ts">
import TextArea from '$lib/shared/TextArea.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import { TopicService } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import { createKeybind } from '$lib/utils/hotkeys';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
interface Props {
registerKeypress?: boolean;
}
const { registerKeypress = false }: Props = $props();
const topicService = getContext(TopicService);
let modal = $state<Modal>();
let title = $state('');
let body = $state('');
let submitProgress = $state<'inert' | 'loading' | 'complete'>('inert');
async function submit() {
submitProgress = 'loading';
topicService.create(title, body);
submitProgress = 'complete';
modal?.close();
}
export function open() {
title = '';
body = '';
submitProgress = 'inert';
modal?.show();
}
let handleKeyDown = $state(() => {});
$effect(() => {
if (registerKeypress) {
handleKeyDown = createKeybind({
'$mod+k': open
});
} else {
handleKeyDown = () => {};
}
});
</script>
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={modal}>
<h2 class="text-18 text-bold">Create an topic</h2>
<div class="input">
<p class="text-14 label">Title</p>
<TextBox bind:value={title} />
</div>
<div class="input">
<p class="text-14 label">Body</p>
<TextArea bind:value={body} />
</div>
{#snippet controls()}
<Button onclick={() => modal?.close()}>Cancel</Button>
<Button kind="solid" style="pop" onclick={submit} loading={submitProgress === 'loading'}
>Submit</Button
>
{/snippet}
</Modal>
<style lang="postcss">
.input {
margin-top: 8px;
}
.label {
margin-bottom: 4px;
}
</style>

View File

@ -0,0 +1,116 @@
<script lang="ts">
import CreateIssueModal from '$lib/topics/CreateIssueModal.svelte';
import { TopicService, type Topic } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/Button.svelte';
import Icon from '@gitbutler/ui/Icon.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
interface Props {
topic: Topic;
}
const { topic }: Props = $props();
const topicService = getContext(TopicService);
let deleteModal = $state<Modal>();
let expanded = $state(false);
let createIssueModal = $state<CreateIssueModal>();
</script>
<CreateIssueModal bind:this={createIssueModal} {topic} />
<Modal bind:this={deleteModal} width="small">
<p>Are you sure you want to delete this topic?</p>
{#snippet controls()}
<Button onclick={() => deleteModal?.close()}>Cancel</Button>
<Button
onclick={() => {
topicService.remove(topic);
deleteModal?.close();
}}
kind="solid"
style="error">Delete</Button
>
{/snippet}
</Modal>
<div class="topic">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="header" onclick={() => (expanded = !expanded)}>
<p class="text-14 text-bold title">{topic.title}</p>
<div class="header__details">
{#if topic.hasIssue}
<Button size="tag" clickable={false}>Has Issue</Button>
{/if}
{#if expanded}
<Icon name="chevron-down" />
{:else}
<Icon name="chevron-up" />
{/if}
</div>
</div>
{#if expanded}
<div class="footer">
<p class="text-14">{topic.body}</p>
<div class="footer__actions">
{#if !topic.hasIssue}
<Button onclick={() => createIssueModal?.open()}>Convert to issue</Button>
{/if}
<Button icon="bin" style="error" onclick={() => deleteModal?.show()} />
</div>
</div>
{/if}
</div>
<style lang="postcss">
.topic {
border-bottom: 1px solid var(--clr-border-2);
&:last-child {
border: none;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
}
.header__details {
display: flex;
gap: 8px;
align-items: center;
}
.footer {
border-top: 1px solid var(--clr-border-3);
width: 100%;
padding: 16px;
}
.footer__actions {
display: flex;
justify-content: flex-end;
width: 100%;
gap: 8px;
}
</style>

View File

@ -0,0 +1,51 @@
import { persisted } from '$lib/persisted/persisted';
import { get, type Readable } from 'svelte/store';
import type { Project } from '$lib/backend/projects';
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
export type Topic = {
title: string;
body: string;
hasIssue: boolean;
createdAt: number;
id: string;
};
export class TopicService {
topics = persisted<Topic[]>([], this.localStorageKey);
constructor(
private project: Project,
private issueService: Readable<GitHostIssueService | undefined>
) {}
private get localStorageKey(): string {
return `TopicService--${this.project.id}`;
}
create(title: string, body: string, hasIssue: boolean = false): Topic {
const topic = {
title,
body,
hasIssue,
createdAt: Date.now(),
id: crypto.randomUUID()
};
this.topics.set([topic, ...get(this.topics)]);
return topic;
}
update(topic: Topic) {
const filteredTopics = get(this.topics).filter((storedTopic) => storedTopic.id !== topic.id);
this.topics.set([topic, ...filteredTopics]);
}
remove(topic: Topic) {
const filteredTopics = get(this.topics).filter((storedTopic) => storedTopic.id !== topic.id);
this.topics.set(filteredTopics);
}
}

View File

@ -16,6 +16,7 @@
gitHostPullRequestTemplatePath, gitHostPullRequestTemplatePath,
gitHostUsePullRequestTemplate gitHostUsePullRequestTemplate
} from '$lib/config/config'; } from '$lib/config/config';
import { featureTopics } from '$lib/config/uiFeatureFlags';
import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager'; import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager';
import { DefaultGitHostFactory } from '$lib/gitHost/gitHostFactory'; import { DefaultGitHostFactory } from '$lib/gitHost/gitHostFactory';
import { octokitFromAccessToken } from '$lib/gitHost/github/octokit'; import { octokitFromAccessToken } from '$lib/gitHost/github/octokit';
@ -28,12 +29,16 @@
import Navigation from '$lib/navigation/Navigation.svelte'; import Navigation from '$lib/navigation/Navigation.svelte';
import { persisted } from '$lib/persisted/persisted'; import { persisted } from '$lib/persisted/persisted';
import { RemoteBranchService } from '$lib/stores/remoteBranches'; import { RemoteBranchService } from '$lib/stores/remoteBranches';
import CreateIssueModal from '$lib/topics/CreateIssueModal.svelte';
import CreateTopicModal from '$lib/topics/CreateTopicModal.svelte';
import { TopicService } from '$lib/topics/service';
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher'; import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { parseRemoteUrl } from '$lib/url/gitUrl'; import { parseRemoteUrl } from '$lib/url/gitUrl';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { onDestroy, setContext, type Snippet } from 'svelte'; import { onDestroy, setContext, type Snippet } from 'svelte';
import { derived as storeDerived } from 'svelte/store';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -93,6 +98,14 @@
const listServiceStore = createGitHostListingServiceStore(undefined); const listServiceStore = createGitHostListingServiceStore(undefined);
const gitHostStore = createGitHostStore(undefined); const gitHostStore = createGitHostStore(undefined);
const branchServiceStore = createBranchServiceStore(undefined); const branchServiceStore = createBranchServiceStore(undefined);
const gitHostIssueSerice = storeDerived(gitHostStore, (gitHostStore) =>
gitHostStore?.issueService()
);
$effect.pre(() => {
const topicService = new TopicService(project, gitHostIssueSerice);
setContext(TopicService, topicService);
});
$effect.pre(() => { $effect.pre(() => {
const combinedBranchListingService = new CombinedBranchListingService( const combinedBranchListingService = new CombinedBranchListingService(
@ -172,8 +185,17 @@
onDestroy(() => { onDestroy(() => {
clearFetchInterval(); clearFetchInterval();
}); });
const topicsEnabled = featureTopics();
</script> </script>
{#if $topicsEnabled}
{#if $gitHostStore?.issueService()}
<CreateIssueModal registerKeypress />
{/if}
<CreateTopicModal registerKeypress />
{/if}
<!-- forces components to be recreated when projectId changes --> <!-- forces components to be recreated when projectId changes -->
{#key projectId} {#key projectId}
<ProjectSettingsMenuAction <ProjectSettingsMenuAction

View File

@ -0,0 +1,59 @@
<script lang="ts">
import SettingsPage from '$lib/layout/SettingsPage.svelte';
import CreateIssueModal from '$lib/topics/CreateIssueModal.svelte';
import CreateTopicModal from '$lib/topics/CreateTopicModal.svelte';
import Topic from '$lib/topics/Topic.svelte';
import { TopicService } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/Button.svelte';
const topicService = getContext(TopicService);
const topics = topicService.topics;
const sortedTopics = $derived.by(() => {
const clonedTopics = structuredClone($topics);
clonedTopics.sort((a, b) => b.createdAt - a.createdAt);
return clonedTopics;
});
let createTopicModal = $state<CreateTopicModal>();
let createIssueModal = $state<CreateIssueModal>();
</script>
<CreateTopicModal bind:this={createTopicModal} />
<CreateIssueModal bind:this={createIssueModal} />
<SettingsPage title="Topics">
<div>
<div class="topic__actions">
<Button kind="solid" style="pop" onclick={() => createTopicModal?.open()}>Create Topic</Button
>
<Button style="pop" onclick={() => createIssueModal?.open()}>Create Issue</Button>
</div>
<div class="container">
{#each sortedTopics as topic}
<Topic {topic} />
{/each}
</div>
</div>
</SettingsPage>
<style lang="postcss">
.container {
width: 100%;
background-color: var(--clr-bg-1);
border-radius: var(--radius-l);
border: 1px solid var(--clr-border-2);
}
.topic__actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -3,7 +3,8 @@
import { import {
featureBaseBranchSwitching, featureBaseBranchSwitching,
featureInlineUnifiedDiffs, featureInlineUnifiedDiffs,
featureBranchStacking featureBranchStacking,
featureTopics
} from '$lib/config/uiFeatureFlags'; } from '$lib/config/uiFeatureFlags';
import SettingsPage from '$lib/layout/SettingsPage.svelte'; import SettingsPage from '$lib/layout/SettingsPage.svelte';
import Toggle from '$lib/shared/Toggle.svelte'; import Toggle from '$lib/shared/Toggle.svelte';
@ -11,6 +12,7 @@
const baseBranchSwitching = featureBaseBranchSwitching(); const baseBranchSwitching = featureBaseBranchSwitching();
const inlineUnifiedDiffs = featureInlineUnifiedDiffs(); const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
const branchStacking = featureBranchStacking(); const branchStacking = featureBranchStacking();
const topicsEnabled = featureTopics();
</script> </script>
<SettingsPage title="Experimental features"> <SettingsPage title="Experimental features">
@ -60,6 +62,20 @@
/> />
</svelte:fragment> </svelte:fragment>
</SectionCard> </SectionCard>
<SectionCard labelFor="topics" orientation="row">
<svelte:fragment slot="title">Topics</svelte:fragment>
<svelte:fragment slot="caption">
A highly experimental form of note taking / conversation. The form & function may change
drastically, and may result in lost notes.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="topics"
checked={$topicsEnabled}
on:click={() => ($topicsEnabled = !$topicsEnabled)}
/>
</svelte:fragment>
</SectionCard>
</SettingsPage> </SettingsPage>
<style> <style>