mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-26 11:08:38 +03:00
commit
5a3d788927
@ -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);
|
||||||
|
}
|
||||||
|
@ -36,6 +36,10 @@ export class AzureDevOps implements GitHost {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issueService() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
prService() {
|
prService() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -40,6 +40,10 @@ export class BitBucket implements GitHost {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issueService() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
prService() {
|
prService() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
29
apps/desktop/src/lib/gitHost/github/issueService.ts
Normal file
29
apps/desktop/src/lib/gitHost/github/issueService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -41,6 +41,10 @@ export class GitLab implements GitHost {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issueService() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
prService() {
|
prService() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface GitHostIssueService {
|
||||||
|
create(title: string, body: string, labels: string[]): Promise<void>;
|
||||||
|
listLabels(): Promise<string[]>;
|
||||||
|
}
|
@ -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>
|
||||||
|
|
||||||
|
34
apps/desktop/src/lib/navigation/TopicsButton.svelte
Normal file
34
apps/desktop/src/lib/navigation/TopicsButton.svelte
Normal 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>
|
149
apps/desktop/src/lib/topics/CreateIssueModal.svelte
Normal file
149
apps/desktop/src/lib/topics/CreateIssueModal.svelte
Normal 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>
|
85
apps/desktop/src/lib/topics/CreateTopicModal.svelte
Normal file
85
apps/desktop/src/lib/topics/CreateTopicModal.svelte
Normal 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>
|
116
apps/desktop/src/lib/topics/Topic.svelte
Normal file
116
apps/desktop/src/lib/topics/Topic.svelte
Normal 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>
|
51
apps/desktop/src/lib/topics/service.ts
Normal file
51
apps/desktop/src/lib/topics/service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
59
apps/desktop/src/routes/[projectId]/topics/+page.svelte
Normal file
59
apps/desktop/src/routes/[projectId]/topics/+page.svelte
Normal 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>
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user