Integration modal design (#5116)

* new IntegrationListItem component

* Simple commit row added, componenets rename

* Tweak styles and `Select` update

* check fix
This commit is contained in:
Pavel Laptev 2024-10-14 11:10:00 +02:00 committed by GitHub
parent 6b6fc9b016
commit c7342b1cdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 571 additions and 236 deletions

View File

@ -27,6 +27,7 @@
import Icon from '@gitbutler/ui/Icon.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
import { getTimeAndAuthor } from '@gitbutler/ui/utils/getTimeAndAuthor';
import { getTimeAgo } from '@gitbutler/ui/utils/timeAgo';
import { type Snippet } from 'svelte';
@ -114,12 +115,6 @@
commitMessageModal.close();
}
function getTimeAndAuthor() {
const timeAgo = getTimeAgo(commit.createdAt);
const author = type === 'localAndRemote' || type === 'remote' ? commit.author.name : 'you';
return `${timeAgo} by ${author}`;
}
const commitShortSha = commit.id.substring(0, 7);
let topHeightPx = 24;
@ -381,7 +376,8 @@
</button>
{/if}
<span class="commit__subtitle-divider"></span>
<span>{getTimeAndAuthor()}</span>
<span>{getTimeAndAuthor(commit.createdAt, commit.author.name)}</span>
</div>
{/if}
</div>

View File

@ -1,10 +1,11 @@
<script lang="ts">
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import CommitCard from '$lib/commit/CommitCard.svelte';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import { openExternalUrl } from '$lib/utils/url';
import {
getBaseBrancheResolution,
getResolutionApproach,
@ -16,8 +17,12 @@
type Resolution
} from '$lib/vbranches/upstreamIntegrationService';
import { getContext } from '@gitbutler/shared/context';
import Badge from '@gitbutler/ui/Badge.svelte';
import Button from '@gitbutler/ui/Button.svelte';
import IntegrationSeriesRow from '@gitbutler/ui/IntegrationSeriesRow.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import SimpleCommitRow from '@gitbutler/ui/SimpleCommitRow.svelte';
import { pxToRem } from '@gitbutler/ui/utils/pxToRem';
import { tick } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { Readable } from 'svelte/store';
@ -40,10 +45,11 @@
let integratingUpstream = $state<OperationState>('inert');
let results = $state(new SvelteMap<string, Resolution>());
let statuses = $state<BranchStatusInfo[]>([]);
let expanded = $state<boolean>(false);
let baseResolutionApproach = $state<BaseBranchResolutionApproach>('hardReset');
let baseResolutionApproach = $state<BaseBranchResolutionApproach | undefined>();
let targetCommitOid = $state<string | undefined>(undefined);
let isDivergedResolved = $derived($base?.diverged && !baseResolutionApproach);
$effect(() => {
if ($branchStatuses?.type !== 'updatesRequired') {
statuses = [];
@ -82,7 +88,7 @@
// Resolve the target commit oid if the base branch diverged and the the resolution
// approach is changed
$effect(() => {
if ($base?.diverged) {
if ($base?.diverged && baseResolutionApproach) {
upstreamIntegrationService.resolveUpstreamIntegration(baseResolutionApproach).then((Oid) => {
targetCommitOid = Oid;
});
@ -96,7 +102,10 @@
async function integrate() {
integratingUpstream = 'loading';
await tick();
const baseResolution = getBaseBrancheResolution(targetCommitOid, baseResolutionApproach);
const baseResolution = getBaseBrancheResolution(
targetCommitOid,
baseResolutionApproach || 'hardReset'
);
await upstreamIntegrationService.integrateUpstream(
Array.from(results.values()),
baseResolution
@ -109,7 +118,6 @@
export async function show() {
integratingUpstream = 'inert';
expanded = false;
branchStatuses = upstreamIntegrationService.upstreamStatuses();
await tick();
modal?.show();
@ -122,244 +130,199 @@
};
</script>
<Modal bind:this={modal} title="Integrate upstream changes" {onClose} width="small" noPadding>
<ScrollableContainer maxHeight="50vh">
<div class="modal-content">
{#if $base}
<div class="upstream-commits">
{#each $base.upstreamCommits.slice(0, 2) as commit, index}
<CommitCard
{commit}
first={index === 0}
last={(() => {
if (expanded) {
return $base.upstreamCommits.length - 1 === index;
} else {
if ($base.upstreamCommits.length > 2) {
return index === 1;
} else {
return $base.upstreamCommits.length - 1 === index;
<Modal bind:this={modal} {onClose} width={560} noPadding>
<!-- <ScrollableContainer maxHeight="50vh"> -->
<ScrollableContainer maxHeight={'70vh'}>
{#if $base}
<div class="section">
<h3 class="text-14 text-semibold section-title">
<span>Incoming changes</span><Badge label={$base.upstreamCommits.length} />
</h3>
<div class="scroll-wrap">
<ScrollableContainer maxHeight={pxToRem(268)}>
{#each $base.upstreamCommits as commit}
<SimpleCommitRow
title={commit.descriptionTitle}
sha={commit.id}
date={commit.createdAt}
author={commit.author.name}
onUrlOpen={() => {
if ($gitHost) {
openExternalUrl($gitHost.commitUrl(commit.id));
}
}
})()}
isUnapplied={true}
commitUrl={$gitHost?.commitUrl(commit.id)}
type="remote"
filesToggleable={false}
/>
{/each}
{#if $base.upstreamCommits.length > 2}
{#if expanded}
{#each $base.upstreamCommits.slice(2) as commit, index}
<CommitCard
{commit}
last={index === $base.upstreamCommits.length - 3}
isUnapplied={true}
commitUrl={$gitHost?.commitUrl(commit.id)}
type="remote"
filesToggleable={false}
/>
{/each}
<div class="commit-expand-button">
<Button wide onclick={() => (expanded = false)}>Hide</Button>
</div>
{:else}
<div class="commit-expand-button">
<Button wide onclick={() => (expanded = true)}
>Show more ({$base.upstreamCommits.length - 2})</Button
>
</div>
{/if}
{/if}
}}
onCopy={() => {
copyToClipboard(commit.id);
}}
/>
{/each}
</ScrollableContainer>
</div>
{/if}
</div>
{/if}
{#if $base?.diverged}
<div class="branch-status base-branch">
<div class="description">
<div class="description-header">
<img class="icon" src="/images/domain-icons/trunk.svg" alt="" />
<h5 class="text-16">{$base.branchName ?? 'Unknown'}</h5>
</div>
<Button
clickable={false}
size="tag"
style="warning"
outline
reversedDirection
shrinkable>Diverged</Button
>
</div>
{#if $base?.diverged}
<div class="target-divergence">
<img class="target-icon" src="/images/domain-icons/trunk.svg" alt="" />
<div class="action">
<Select
value={baseResolutionApproach}
onselect={handleBaseResolutionSelection}
options={[
{ label: 'Rebase', value: 'rebase' },
{ label: 'Merge', value: 'merge' },
{ label: 'Hard reset', value: 'hardReset' }
]}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={highlighted} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
</div>
<div class="target-divergence-about">
<h3 class="text-14 text-semibold">Target branch divergence</h3>
<p class="text-12 text-body target-divergence-description">
Branch target/main has diverged from the workspace.
<br />
Select an action to proceed with updating.
</p>
</div>
{/if}
{#if statuses.length > 0}
<div class="statuses">
{#each statuses as { branch, status }}
<div class="branch-status" class:integrated={status.type === 'fullyIntegrated'}>
<div class="description">
<div class="description-header">
<Button
clickable={false}
size="tag"
icon="virtual-branch-small"
style="neutral"
reversedDirection
/>
<h5 class="text-16">{branch?.name || 'Unknown'}</h5>
</div>
{#if status.type === 'conflicted'}
<Button clickable={false} size="tag" style="warning" outline reversedDirection
>Conflicted</Button
>
{:else if status.type === 'saflyUpdatable' || status.type === 'empty'}
<Button clickable={false} size="tag" style="neutral" outline reversedDirection
>No conflicts</Button
>
{:else if status.type === 'fullyIntegrated'}
<Button
clickable={false}
size="tag"
icon="pr-small"
style="success"
kind="solid"
reversedDirection>Integrated</Button
>
{/if}
</div>
<div class="action" class:action--centered={status.type === 'fullyIntegrated'}>
{#if status.type === 'fullyIntegrated'}
<p class="text-12 text-light info">Changes included in base branch</p>
{:else if results.get(branch.id)}
<Select
value={results.get(branch.id)!.approach.type}
onselect={(value) => {
const result = results.get(branch.id)!;
results.set(branch.id, { ...result, approach: { type: value } });
}}
options={[
{ label: 'Rebase', value: 'rebase' },
{ label: 'Merge', value: 'merge' },
{ label: 'Stash', value: 'unapply' }
]}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={highlighted} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{/if}
</div>
</div>
{/each}
<div class="target-divergence-action">
<Select
value={baseResolutionApproach}
placeholder="Choose…"
onselect={handleBaseResolutionSelection}
options={[
{ label: 'Rebase', value: 'rebase' },
{ label: 'Merge', value: 'merge' },
{ label: 'Hard reset', value: 'hardReset' }
]}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={highlighted} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
</div>
{/if}
</div>
</div>
{/if}
{#if statuses.length > 0}
<div class="section" class:section-disabled={isDivergedResolved}>
<h3 class="text-14 text-semibold">To be updated:</h3>
<div class="scroll-wrap">
<ScrollableContainer maxHeight={pxToRem(240)}>
{#each statuses as { branch, status }}
<IntegrationSeriesRow
type={status.type === 'fullyIntegrated'
? 'integrated'
: status.type === 'conflicted'
? 'conflicted'
: 'clear'}
title={branch.name}
>
{#snippet select()}
{#if status.type !== 'fullyIntegrated' && results.get(branch.id)}
<Select
value={results.get(branch.id)!.approach.type}
onselect={(value) => {
const result = results.get(branch.id)!;
results.set(branch.id, { ...result, approach: { type: value } });
}}
options={[
{ label: 'Rebase', value: 'rebase' },
{ label: 'Merge', value: 'merge' },
{ label: 'Stash', value: 'unapply' }
]}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={highlighted} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{/if}
{/snippet}
</IntegrationSeriesRow>
{/each}
</ScrollableContainer>
</div>
</div>
{/if}
</ScrollableContainer>
{#snippet controls()}
<Button onclick={() => modal?.close()}>Cancel</Button>
<Button onclick={integrate} style="pop" kind="solid" loading={integratingUpstream === 'loading'}
>Integrate</Button
>
<div class="controls">
<Button onclick={() => modal?.close()} style="ghost" outline>Cancel</Button>
<Button
wide
onclick={integrate}
style="pop"
kind="solid"
disabled={isDivergedResolved}
loading={integratingUpstream === 'loading'}>Update workspace</Button
>
</div>
{/snippet}
</Modal>
<style>
.icon {
border-radius: var(--radius-s);
height: 20px;
width: 20px;
flex-shrink: 0;
}
.upstream-commits {
text-align: left;
padding: 0 16px;
}
.modal-content {
/* INCOMING CHANGES */
.section {
display: flex;
flex-direction: column;
padding: 16px;
gap: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--clr-border-2);
&:last-child {
border-bottom: none;
}
.scroll-wrap {
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
overflow: hidden;
}
}
.statuses {
box-sizing: border-box;
.section-title {
display: flex;
align-items: center;
gap: 4px;
}
/* DIVERGANCE */
.target-divergence {
display: flex;
padding: 16px;
gap: 14px;
border-bottom: 1px solid var(--clr-border-2);
background-color: var(--clr-theme-warn-bg);
}
.target-icon {
width: 16px;
height: 16px;
border-radius: var(--radius-s);
}
.target-divergence-about {
display: flex;
width: 100%;
flex-direction: column;
justify-content: space-around;
}
.branch-status {
display: flex;
justify-content: space-between;
padding: 14px;
&.base-branch {
border-top: 1px solid var(--clr-border-2);
border-bottom: 1px solid var(--clr-border-2);
}
&.integrated {
background-color: var(--clr-bg-2);
}
& .description {
display: flex;
flex-direction: column;
gap: 8px;
text-align: left;
}
& .action {
width: 144px;
&.action--centered {
display: flex;
align-items: center;
justify-content: center;
}
& .info {
color: var(--clr-text-2);
}
}
}
.description-header {
display: flex;
gap: 8px;
}
.commit-expand-button {
margin: 8px -16px;
padding: 0 16px;
padding-bottom: 8px;
.target-divergence-description {
color: var(--clr-text-2);
}
border-bottom: 1px solid var(--clr-border-2);
.target-divergence-action {
display: flex;
flex-direction: column;
max-width: 230px;
}
/* CONTROLS */
.controls {
display: flex;
width: 100%;
gap: 6px;
}
/* MODIFIERS */
.section-disabled {
opacity: 0.5;
pointer-events: none;
}
</style>

View File

@ -39,7 +39,7 @@
wide,
options = [],
value,
placeholder,
placeholder = 'Select an option...',
maxHeight,
searchable,
itemSnippet,
@ -161,7 +161,7 @@
type="select"
reversedDirection
icon="select-chevron"
value={options.find((item) => item.value === value)?.label || 'Select an option...'}
value={options.find((item) => item.value === value)?.label}
disabled={disabled || loading}
on:mousedown={toggleList}
on:keydown={(ev) => handleKeyDown(ev)}

View File

@ -0,0 +1,139 @@
<script lang="ts" module>
import type { Snippet } from 'svelte';
export interface Props {
type: 'clear' | 'conflicted' | 'integrated';
title: string;
select: Snippet;
}
</script>
<script lang="ts">
import Icon from '$lib/Icon.svelte';
let { type, title, select }: Props = $props();
</script>
<div class="integration-series-item no-select {type}">
<div class="branch-icon">
<Icon name="remote-branch-small" />
</div>
<div class="name-label-wrap">
<span class="name-label text-13 text-semibold truncate">
{title}
</span>
<span class="name-label-badge text-11 text-semibold">
{#if type === 'clear'}
<span>Clear</span>
{:else if type === 'conflicted'}
<span>Conflicted</span>
{:else if type === 'integrated'}
<span>Integrated</span>
{/if}
</span>
</div>
{#if select}
<div class="select">
{@render select()}
</div>
{/if}
{#if type === 'integrated'}
<div class="integrated-label-wrap">
<Icon name="tick-small" />
<span class="integrated-label text-12"> Part of the new base </span>
</div>
{/if}
</div>
<style lang="postcss">
.integration-series-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 12px 12px 14px;
min-height: 56px;
border-bottom: 1px solid var(--clr-border-2);
&:last-child {
border-bottom: none;
}
.branch-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: var(--radius-s);
color: var(--clr-core-ntrl-100);
}
/* NAME LABEL */
.name-label-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.name-label {
color: var(--clr-text-1);
}
.name-label-badge {
padding: 2px 4px;
border-radius: var(--radius-m);
color: var(--clr-core-ntrl-100);
}
/* INTEGRATED LABEL */
.integrated-label-wrap {
display: flex;
align-items: center;
gap: 4px;
padding-left: 6px;
margin-right: 2px;
color: var(--clr-text-2);
}
.integrated-label {
white-space: nowrap;
}
.select {
max-width: 130px;
}
/* MODIFIERS */
&.clear {
background-color: var(--clr-bg-1);
.branch-icon {
background-color: var(--clr-core-ntrl-50);
}
}
&.conflicted {
background-color: var(--clr-bg-1);
.branch-icon,
.name-label-badge {
background-color: var(--clr-theme-warn-on-element);
background-color: var(--clr-theme-warn-element);
}
}
&.integrated {
background-color: var(--clr-bg-1-muted);
.branch-icon,
.name-label-badge {
color: var(--clr-theme-purp-on-element);
background-color: var(--clr-theme-purp-element);
}
}
}
</style>

View File

@ -1,11 +1,12 @@
<script lang="ts">
import Icon from '$lib/Icon.svelte';
import { clickOutside } from '$lib/utils/clickOutside';
import { pxToRem } from '$lib/utils/pxToRem';
import type iconsJson from '$lib/data/icons.json';
import type { Snippet } from 'svelte';
interface Props {
width?: 'default' | 'medium-large' | 'large' | 'small' | 'xsmall';
width?: 'default' | 'medium-large' | 'large' | 'small' | 'xsmall' | number;
title?: string;
icon?: keyof typeof iconsJson;
noPadding?: boolean;
@ -63,6 +64,7 @@
class:large={width === 'large'}
class:small={width === 'small'}
class:xsmall={width === 'xsmall'}
style:width={typeof width === 'number' ? pxToRem(width) : undefined}
>
{#if open}
<form
@ -117,6 +119,9 @@
background-color: var(--clr-bg-1);
box-shadow: var(--fx-shadow-l);
/* fix for the native dialog "inherit" issue */
text-align: left;
animation: dialog-zoom 0.25s cubic-bezier(0.34, 1.35, 0.7, 1);
/* MODIFIERS */

View File

@ -0,0 +1,114 @@
<script lang="ts" module>
// import type { Snippet } from 'svelte';
export interface Props {
title: string;
sha: string;
date: Date;
author?: string;
onCopy?: () => void;
onUrlOpen?: () => void;
}
</script>
<script lang="ts">
import Icon from '$lib/Icon.svelte';
import { getTimeAndAuthor } from '$lib/utils/getTimeAndAuthor';
let { title, sha, author, date, onCopy, onUrlOpen }: Props = $props();
</script>
<div class="simple-commit-item no-select">
<Icon name="commit" />
<div class="content">
<span class="title text-13 text-semibold">
{title}
</span>
<div class="details text-11">
<button class="details-btn copy-btn" onclick={onCopy}>
<span>{sha.substring(0, 7)}</span>
<Icon name="copy-small" />
</button>
<span class="details-divider"></span>
<button class="details-btn link-btn" onclick={onUrlOpen}>
<span>Open</span>
<Icon name="open-link" />
</button>
<span class="details-divider"></span>
<span class="truncate">{getTimeAndAuthor(date, author)}</span>
</div>
</div>
</div>
<style lang="postcss">
.simple-commit-item {
display: flex;
gap: 10px;
padding: 12px 14px 14px 12px;
border-bottom: 1px solid var(--clr-border-2);
&:last-child {
border-bottom: none;
}
.content {
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
/* Fix because of using native dialog element */
& span {
text-align: left;
}
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
display: flex;
align-items: center;
gap: 4px;
color: var(--clr-text-2);
overflow: hidden;
}
.details-btn {
display: flex;
align-items: center;
transition: color var(--transition-fast);
& span {
margin-right: 4px;
}
&:hover {
color: var(--clr-text-1);
}
}
.copy-btn {
& span {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
}
.link-btn {
& span {
text-decoration: underline;
text-underline-offset: 3px;
}
}
.details-divider {
color: var(--clr-text-3);
line-height: 150%;
}
}
</style>

View File

@ -0,0 +1,11 @@
import { getTimeAgo } from './timeAgo';
export function getTimeAndAuthor(createdAt: Date, name: string | undefined): string {
const timeAgo = getTimeAgo(createdAt);
if (name) {
return `${timeAgo} by ${name}`;
}
return timeAgo;
}

View File

@ -0,0 +1,27 @@
<script lang="ts">
import IntegrationSeriesListItem, {
type Props as SeriesProps
} from '$lib/IntegrationSeriesRow.svelte';
const items = [
{ type: 'clear', title: 'Move context into shared' },
{ type: 'conflicted', title: 'Add new context' },
{ type: 'integrated', title: 'Make edit mode fantastic' }
] as SeriesProps[];
</script>
<div class="wrapper">
{#each items as item}
<IntegrationSeriesListItem {...item} />
{/each}
</div>
<style>
.wrapper {
display: flex;
flex-direction: column;
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
overflow: hidden;
}
</style>

View File

@ -0,0 +1,15 @@
import DemoIntegrationSeriesRow from './DemoIntegrationSeriesRow.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta: Meta = {
title: 'List items / Integration Series Row',
component: DemoIntegrationSeriesRow
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Story: Story = {
name: 'Integration Series Row',
args: {}
};

View File

@ -0,0 +1,46 @@
<script lang="ts">
import SimpleCommitRow, { type Props as SeriesProps } from '$lib/SimpleCommitRow.svelte';
const items = [
{
title: 'feat: add user authentication',
sha: 'b52f23',
author: 'Jane Smith',
date: new Date('2022-01-15T08:30:00Z')
},
{
title: 'refactor: improve database queries',
sha: 'a9f732',
author: 'Mike Brown',
date: new Date('2023-05-23T14:15:00Z')
},
{
title: 'fix: correct null pointer exception',
sha: 'e7c245',
author: 'Alice White',
date: new Date('2023-07-12T09:45:00Z')
},
{
title: 'docs: update README with API examples',
sha: '2ac923',
author: 'Sam Green',
date: new Date('2021-10-21T17:00:00Z')
}
] as SeriesProps[];
</script>
<div class="wrapper">
{#each items as item}
<SimpleCommitRow {...item} />
{/each}
</div>
<style>
.wrapper {
display: flex;
flex-direction: column;
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
overflow: hidden;
}
</style>

View File

@ -0,0 +1,15 @@
import DemoSimpleCommitRow from './DemoSimpleCommitRow.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta: Meta = {
title: 'List items / Simple Commit Row',
component: DemoSimpleCommitRow
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Story: Story = {
name: 'Simple Commit Row',
args: {}
};

View File

@ -3,8 +3,8 @@ import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
title: 'Inputs / Checkbox',
component: DemoCheckbox
} satisfies Meta<DemoCheckbox>;
component: DemoCheckbox as any
} satisfies Meta<typeof DemoCheckbox>;
export default meta;
type Story = StoryObj<typeof meta>;

View File

@ -2,7 +2,7 @@ import FileIcon from '$lib/file/FileIcon.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
title: 'Files / File Icon',
title: 'Elements / File Icon',
component: FileIcon
} satisfies Meta<FileIcon>;

View File

@ -2,9 +2,9 @@ import DemoFileListItem from './DemoFileListItem.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
title: 'Files / FileListItem',
component: DemoFileListItem
} satisfies Meta<DemoFileListItem>;
title: 'List items / FileListItem',
component: DemoFileListItem as any
} satisfies Meta<typeof DemoFileListItem>;
export default meta;
type Story = StoryObj<typeof meta>;

View File

@ -52,3 +52,7 @@ pre {
text-overflow: ellipsis;
white-space: nowrap;
}
.no-select {
user-select: none;
}