Skip to content

Commit 9bb2fb8

Browse files
committed
Add file type override for multimodal file input
1 parent a19e4eb commit 9bb2fb8

File tree

7 files changed

+142
-23
lines changed

7 files changed

+142
-23
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script lang="ts" module>
2+
import { type BundledLanguage, bundledLanguages, type SpecialLanguage } from "shiki";
3+
4+
const languageKeys = [...Object.keys(bundledLanguages), "text", "ansi", "plaintext", "txt"] as BundledLanguage | SpecialLanguage[];
5+
</script>
6+
7+
<script lang="ts">
8+
import { Label, Select } from "bits-ui";
9+
10+
interface Props {
11+
value?: BundledLanguage | SpecialLanguage | "auto";
12+
allowAuto?: boolean;
13+
}
14+
15+
let { value = $bindable("auto"), allowAuto = true }: Props = $props();
16+
17+
const uid = $props.id();
18+
const fileTypeId = `file-type-${uid}`;
19+
const fileTypeLabelId = `file-type-label-${uid}`;
20+
</script>
21+
22+
<div class="flex items-center gap-1">
23+
<Label.Root id={fileTypeLabelId} for={fileTypeId} class="text-sm">File Type</Label.Root>
24+
<Select.Root type="single" bind:value scrollAlignment="center">
25+
<Select.Trigger id={fileTypeId} aria-labelledby={fileTypeLabelId} class="flex items-center gap-1 rounded-sm border btn-ghost px-2 text-sm">
26+
{#if value === "auto"}
27+
Infer Type
28+
{:else}
29+
{value}
30+
{/if}
31+
<span aria-hidden="true" class="iconify size-4 shrink-0 text-base text-em-disabled octicon--triangle-down-16"></span>
32+
</Select.Trigger>
33+
<Select.Portal>
34+
<Select.Content class="z-100 mt-0.5 flex max-h-64 flex-col rounded-sm border bg-neutral p-1.5 shadow-md">
35+
{#if allowAuto}
36+
<Select.Group class="mb-1">
37+
<Select.Item
38+
value="auto"
39+
class="cursor-default rounded-sm px-2 py-1 text-sm data-highlighted:bg-neutral-3 data-selected:bg-primary data-selected:text-white"
40+
>
41+
Infer Type
42+
</Select.Item>
43+
</Select.Group>
44+
{/if}
45+
<Select.Group class="flex grow flex-col gap-1 overflow-y-auto">
46+
{#each languageKeys as langKey (langKey)}
47+
<Select.Item
48+
value={langKey}
49+
class="cursor-default rounded-sm px-2 py-1 text-sm data-highlighted:bg-neutral-3 data-selected:bg-primary data-selected:text-white"
50+
>
51+
{langKey}
52+
</Select.Item>
53+
{/each}
54+
</Select.Group>
55+
</Select.Content>
56+
</Select.Portal>
57+
</Select.Root>
58+
</div>

web/src/lib/components/files/MultimodalFileInput.svelte

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import { box } from "svelte-toolbelt";
44
import { RadioGroup } from "bits-ui";
55
import SingleFileInput from "$lib/components/files/SingleFileInput.svelte";
6+
import FileTypeSelect from "$lib/components/files/FileTypeSelect.svelte";
67
7-
let { state = $bindable(), label = "File", required = false }: MultimodalFileInputProps = $props();
8+
let { state = $bindable(), label = "File", required = false, fileTypeOverride = true }: MultimodalFileInputProps = $props();
89
910
const instance = new MultimodalFileInputState({
1011
state,
1112
label: box.with(() => label),
1213
required: box.with(() => required),
14+
fileTypeOverride: box.with(() => fileTypeOverride),
1315
});
1416
state = instance;
1517
@@ -74,12 +76,11 @@
7476
</script>
7577

7678
{#snippet radioItem(name: string)}
77-
<RadioGroup.Item value={name.toLowerCase()}>
78-
{#snippet children({ checked })}
79-
<span class="rounded-sm px-1 py-0.5 text-sm" class:btn-ghost={!checked} class:border={!checked} class:btn-primary={checked}>
80-
{name}
81-
</span>
82-
{/snippet}
79+
<RadioGroup.Item
80+
value={name.toLowerCase()}
81+
class="rounded-sm px-2 text-sm data-[state=checked]:btn-primary data-[state=unchecked]:border data-[state=unchecked]:btn-ghost"
82+
>
83+
{name}
8384
</RadioGroup.Item>
8485
{/snippet}
8586

@@ -91,11 +92,16 @@
9192
ondrop={handleDrop}
9293
ondragleavecapture={handleDragLeave}
9394
>
94-
<RadioGroup.Root class="mb-1 flex w-full gap-1" bind:value={instance.mode}>
95-
{@render radioItem("File")}
96-
{@render radioItem("URL")}
97-
{@render radioItem("Text")}
98-
</RadioGroup.Root>
95+
<div class="mb-1 flex w-full flex-wrap items-center gap-1">
96+
<RadioGroup.Root class="me-2 flex gap-1" bind:value={instance.mode}>
97+
{@render radioItem("File")}
98+
{@render radioItem("URL")}
99+
{@render radioItem("Text")}
100+
</RadioGroup.Root>
101+
{#if fileTypeOverride}
102+
<FileTypeSelect allowAuto={instance.mode !== "text"} bind:value={() => instance.getFileType(), (v) => instance.setFileType(v)} />
103+
{/if}
104+
</div>
99105
{#if instance.mode === "file"}
100106
{@render fileInput()}
101107
{:else if instance.mode === "url"}

web/src/lib/components/files/index.svelte.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type ReadableBoxedValues } from "svelte-toolbelt";
2-
import { lazyPromise } from "$lib/util";
2+
import { getExtensionForLanguage, lazyPromise } from "$lib/util";
3+
import type { BundledLanguage, SpecialLanguage } from "shiki";
34

45
export interface FileSystemEntry {
56
fileName: string;
@@ -132,6 +133,8 @@ function filesToDirectory(files: FileList): DirectoryEntry {
132133
return ret;
133134
}
134135

136+
export type FileType = SpecialLanguage | BundledLanguage | "auto";
137+
135138
export type FileInputMode = "file" | "url" | "text";
136139

137140
export type MultimodalFileInputValueMetadata = {
@@ -144,21 +147,26 @@ export type MultimodalFileInputProps = {
144147

145148
label?: string | undefined;
146149
required?: boolean | undefined;
150+
fileTypeOverride?: boolean | undefined;
147151
};
148152

149153
export type MultimodalFileInputStateProps = {
150154
state: MultimodalFileInputState | undefined;
151155
} & ReadableBoxedValues<{
152156
label: string;
153157
required: boolean;
158+
fileTypeOverride: boolean;
154159
}>;
155160

156161
export class MultimodalFileInputState {
157162
private readonly opts: MultimodalFileInputStateProps;
158163
mode: FileInputMode = $state("file");
159164
text: string = $state("");
165+
textType: FileType = $state("plaintext");
160166
file: File | undefined = $state(undefined);
167+
fileType: FileType = $state("auto");
161168
url: string = $state("");
169+
urlType: FileType = $state("auto");
162170
private urlResolver = $derived.by(() => {
163171
const url = this.url;
164172
return lazyPromise(async () => {
@@ -185,22 +193,56 @@ export class MultimodalFileInputState {
185193
if (this.opts.state) {
186194
this.mode = this.opts.state.mode;
187195
this.text = this.opts.state.text;
196+
this.textType = this.opts.state.textType;
188197
this.file = this.opts.state.file;
198+
this.fileType = this.opts.state.fileType;
189199
this.url = this.opts.state.url;
200+
this.urlType = this.opts.state.urlType;
190201
this.urlResolver = this.opts.state.urlResolver;
191202
}
192203
}
193204

205+
getFileType(): FileType {
206+
if (this.mode === "file") {
207+
return this.fileType;
208+
} else if (this.mode === "url") {
209+
return this.urlType;
210+
} else if (this.mode === "text") {
211+
return this.textType;
212+
}
213+
throw new Error("Invalid mode");
214+
}
215+
216+
setFileType(fileType: FileType) {
217+
if (this.mode === "file") {
218+
this.fileType = fileType;
219+
} else if (this.mode === "url") {
220+
this.urlType = fileType;
221+
} else if (this.mode === "text") {
222+
this.textType = fileType;
223+
} else {
224+
throw new Error("Invalid mode");
225+
}
226+
}
227+
228+
private getExtensionOrBlank() {
229+
const fileType = this.getFileType();
230+
if (fileType === "auto") {
231+
return "";
232+
}
233+
return getExtensionForLanguage(fileType);
234+
}
235+
194236
get metadata(): MultimodalFileInputValueMetadata | null {
195237
const mode = this.mode;
196238
const label = this.opts.label.current;
197239
if (mode === "file" && this.file !== undefined) {
198240
const file = this.file;
199-
return { type: "file", name: file.name };
241+
return { type: "file", name: `${file.name}${this.getExtensionOrBlank()}` };
200242
} else if (mode === "url" && this.url !== "") {
201-
return { type: "url", name: this.url };
243+
return { type: "url", name: `${this.url}${this.getExtensionOrBlank()}` };
202244
} else if (mode === "text" && this.text !== "") {
203-
return { type: "text", name: `${label} (Text Input)` };
245+
return { type: "text", name: `${label}${this.getExtensionOrBlank()}` };
204246
} else {
205247
return null;
206248
}
@@ -228,20 +270,29 @@ export class MultimodalFileInputState {
228270
swapState(other: MultimodalFileInputState) {
229271
const mode = this.mode;
230272
const text = this.text;
273+
const textType = this.textType;
231274
const file = this.file;
275+
const fileType = this.fileType;
232276
const url = this.url;
277+
const urlType = this.urlType;
233278
const urlResolver = this.urlResolver;
234279

235280
this.mode = other.mode;
236281
this.text = other.text;
282+
this.textType = other.textType;
237283
this.file = other.file;
284+
this.fileType = other.fileType;
238285
this.url = other.url;
286+
this.urlType = other.urlType;
239287
this.urlResolver = other.urlResolver;
240288

241289
other.mode = mode;
242290
other.text = text;
291+
other.textType = textType;
243292
other.file = file;
293+
other.fileType = fileType;
244294
other.url = url;
295+
other.urlType = urlType;
245296
other.urlResolver = urlResolver;
246297
}
247298
}

web/src/lib/components/settings-popover/ShikiThemeSelector.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222
<Select.Trigger aria-labelledby={labelId} id={triggerId} class="group flex cursor-pointer items-center justify-between gap-1 px-2 py-1">
2323
<Label.Root id={labelId} for={triggerId} class="cursor-pointer text-sm">{capitalizeFirstLetter(mode)} theme</Label.Root>
2424
<div
25-
class="flex w-44 items-center gap-1 rounded-sm border btn-ghost bg-neutral px-1 py-0.5 text-sm select-none group-hover:btn-ghost-hover group-active:btn-ghost-active"
25+
class="flex w-44 items-center gap-1 rounded-sm border btn-ghost bg-neutral px-2 py-0.5 text-sm select-none group-hover:btn-ghost-hover group-active:btn-ghost-active"
2626
bind:this={anchor}
2727
>
2828
<div bind:clientWidth={triggerLabelContainerW} class="flex grow overflow-hidden" class:reveal-right={scrollDistance !== 0}>
2929
<div
3030
use:resizeObserver={(e) => (triggerLabelW = e[0].target.scrollWidth)}
3131
aria-label="Current {mode} syntax highlighting theme"
32-
class="scrolling-text grow text-center text-nowrap"
32+
class="scrolling-text grow text-left text-nowrap"
3333
style="--scroll-distance: -{scrollDistance}px;"
3434
>
3535
{value}

web/src/lib/diff-viewer-multi-file.svelte.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,7 @@ export class MultiFileDiffViewerState {
282282
private static readonly context = new Context<MultiFileDiffViewerState>("MultiFileDiffViewerState");
283283

284284
static init() {
285-
const state = new MultiFileDiffViewerState();
286-
MultiFileDiffViewerState.context.set(state);
287-
return state;
285+
return MultiFileDiffViewerState.context.set(new MultiFileDiffViewerState());
288286
}
289287

290288
static get() {

web/src/lib/util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,12 @@ const languageMap: { [key: string]: BundledLanguage | SpecialLanguage } = {
373373
".yml": "yaml",
374374
};
375375

376+
const reverseLanguageMap = Object.fromEntries(Object.entries(languageMap).map(([ext, lang]) => [lang, ext]));
377+
378+
export function getExtensionForLanguage(language: BundledLanguage | SpecialLanguage): string {
379+
return reverseLanguageMap[language] || ".txt";
380+
}
381+
376382
export function guessLanguageFromExtension(fileName: string): BundledLanguage | SpecialLanguage {
377383
const lowerFileName = fileName.toLowerCase();
378384
const extensionIndex = lowerFileName.lastIndexOf(".");

web/src/routes/LoadDiffDialog.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@
382382
<Dialog.Portal>
383383
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/50 dark:bg-white/20" />
384384
<Dialog.Content
385-
class="fixed top-1/2 left-1/2 z-50 max-h-svh w-192 max-w-[95%] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-md bg-neutral shadow-md"
385+
class="fixed top-1/2 left-1/2 z-50 max-h-svh w-192 max-w-full -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-md bg-neutral shadow-md sm:max-w-[95%]"
386386
>
387387
<header class="sticky top-0 z-10 flex flex-row items-center justify-between rounded-t-md bg-neutral-2 p-4">
388388
<Dialog.Title class="text-xl font-semibold">Load a diff</Dialog.Title>
@@ -464,7 +464,7 @@
464464
<span class="iconify size-6 shrink-0 octicon--file-diff-24"></span>
465465
From Patch File
466466
</h3>
467-
<MultimodalFileInput bind:state={patchFile} required label="Patch File" />
467+
<MultimodalFileInput bind:state={patchFile} required fileTypeOverride={false} label="Patch File" />
468468
<Button.Root type="submit" class="mt-1 rounded-md btn-primary px-2 py-1">Go</Button.Root>
469469
</form>
470470

0 commit comments

Comments
 (0)