Compare commits

...

3 Commits

Author SHA1 Message Date
Gregory Schier
8b851d4685 Fix better 2025-11-25 09:46:02 -08:00
Gregory Schier
20e1b5c00e Fix dialog and invalid variable style 2025-11-25 09:37:19 -08:00
Gregory Schier
c4ab2965f7 Scrollable tables, specify multi-part filename, fix required prop in prompt, better tab padding 2025-11-25 08:45:33 -08:00
19 changed files with 283 additions and 168 deletions

View File

@@ -33,9 +33,10 @@ export const plugin: PluginDefinition = {
name: 'token',
type: 'text',
label: '1Password Service Account Token',
description: '',
description:
'Token can be generated from the 1Password website by visiting Developer > Service Accounts',
// biome-ignore lint/suspicious/noTemplateCurlyInString: Yaak template syntax
defaultValue: '${[ONEPASSWORD_TOKEN]}',
defaultValue: '${[1PASSWORD_TOKEN]}',
password: true,
},
{

View File

@@ -10,6 +10,7 @@
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:path:allow-resolve-directory",
"core:path:allow-basename",
"os:allow-os-type",
"clipboard-manager:allow-clear",
"clipboard-manager:allow-write-text",

View File

@@ -398,11 +398,16 @@ pub async fn send_http_request_with_context<R: Runtime>(
// Set a file path if it is not empty
if !file_path.is_empty() {
let filename = PathBuf::from(file_path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let user_filename = get_str(p, "filename").to_owned();
let filename = if user_filename.is_empty() {
PathBuf::from(file_path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
user_filename
};
part = part.file_name(filename);
}

View File

@@ -1,12 +1,20 @@
import type { Folder } from '@yaakapp-internal/models';
import { createWorkspaceModel } from '@yaakapp-internal/models';
import { createWorkspaceModel, type Folder, modelTypeLabel } from '@yaakapp-internal/models';
import { applySync, calculateSync } from '@yaakapp-internal/sync';
import { Banner } from '../components/core/Banner';
import { Button } from '../components/core/Button';
import { InlineCode } from '../components/core/InlineCode';
import { VStack } from '../components/core/Stacks';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from '../components/core/Table';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { showConfirm } from '../lib/confirm';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
import { pluralizeCount } from '../lib/pluralize';
import { showPrompt } from '../lib/prompt';
@@ -71,72 +79,92 @@ export const syncWorkspace = createFastMutation<
console.log('Directory changes detected', { dbOps, ops });
const confirmed = force
? true
: await showConfirm({
id: 'commit-sync',
title: 'Changes Detected',
confirmText: 'Apply Changes',
color: isDeletingWorkspace ? 'danger' : 'primary',
description: (
<VStack space={3}>
{isDeletingWorkspace && (
<Banner color="danger">
🚨 <strong>Changes contain a workspace deletion!</strong>
</Banner>
)}
<p>
{pluralizeCount('file', dbOps.length)} in the directory{' '}
{dbOps.length === 1 ? 'has' : 'have'} changed. Do you want to update your workspace?
</p>
<div className="overflow-y-auto max-h-[10rem]">
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="py-1 text-left">Name</th>
<th className="py-1 text-right pl-4">Operation</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{dbOps.map((op, i) => {
let name = '';
let label = '';
let color = '';
if (op.type === 'dbCreate') {
label = 'create';
name = resolvedModelNameWithFolders(op.fs.model);
color = 'text-success';
} else if (op.type === 'dbUpdate') {
label = 'update';
name = resolvedModelNameWithFolders(op.fs.model);
color = 'text-info';
} else if (op.type === 'dbDelete') {
label = 'delete';
name = resolvedModelNameWithFolders(op.model);
color = 'text-danger';
} else {
return null;
}
return (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<tr key={i} className="text-text">
<td className="py-1">{name}</td>
<td className="py-1 pl-4 text-right">
<InlineCode className={color}>{label}</InlineCode>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</VStack>
),
});
if (confirmed) {
if (force) {
await applySync(workspaceId, syncDir, ops);
return;
}
showDialog({
id: 'commit-sync',
title: 'Changes Detected',
size: 'md',
render: ({ hide }) => (
<form
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
onSubmit={async (e) => {
e.preventDefault();
await applySync(workspaceId, syncDir, ops);
hide();
}}
>
{isDeletingWorkspace ? (
<Banner color="danger">
🚨 <strong>Changes contain a workspace deletion!</strong>
</Banner>
) : (
<span />
)}
<p>
{pluralizeCount('file', dbOps.length)} in the directory{' '}
{dbOps.length === 1 ? 'has' : 'have'} changed. Do you want to update your workspace?
</p>
<Table scrollable className="my-4">
<TableHead>
<TableRow>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Operation</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{dbOps.map((op, i) => {
let name: string;
let label: string;
let color: string;
let model: string;
if (op.type === 'dbCreate') {
label = 'create';
name = resolvedModelNameWithFolders(op.fs.model);
color = 'text-success';
model = modelTypeLabel(op.fs.model);
} else if (op.type === 'dbUpdate') {
label = 'update';
name = resolvedModelNameWithFolders(op.fs.model);
color = 'text-info';
model = modelTypeLabel(op.fs.model);
} else if (op.type === 'dbDelete') {
label = 'delete';
name = resolvedModelNameWithFolders(op.model);
color = 'text-danger';
model = modelTypeLabel(op.model);
} else {
return null;
}
return (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<TableRow key={i}>
<TableCell className="text-text-subtle">{model}</TableCell>
<TruncatedWideTableCell>{name}</TruncatedWideTableCell>
<TableCell className="text-right">
<InlineCode className={color}>{label}</InlineCode>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<footer className="py-3 flex flex-row-reverse items-center gap-3">
<Button type="submit" color="primary">
Apply Changes
</Button>
<Button onClick={hide} color="secondary">
Cancel
</Button>
</footer>
</form>
),
});
},
});

View File

@@ -17,6 +17,7 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props
name: p.name,
value: p.file ?? p.value,
contentType: p.contentType,
filename: p.filename,
isFile: !!p.file,
id: p.id,
})),
@@ -30,6 +31,7 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props
enabled: p.enabled,
name: p.name,
contentType: p.contentType,
filename: p.filename,
file: p.isFile ? p.value : undefined,
value: p.isFile ? undefined : p.value,
id: p.id,

View File

@@ -14,6 +14,7 @@ import { HStack } from './core/Stacks';
type Props = Omit<ButtonProps, 'type'> & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
filePath: string | null;
nameOverride?: string | null;
directory?: boolean;
inline?: boolean;
noun?: string;
@@ -31,6 +32,7 @@ export function SelectFile({
className,
directory,
noun,
nameOverride,
size = 'sm',
label,
help,
@@ -88,6 +90,8 @@ export function SelectFile({
};
}, [isHovering, onChange]);
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
return (
<div ref={ref} className="w-full">
{label && (
@@ -110,7 +114,7 @@ export function SelectFile({
{...props}
>
{rtlEscapeChar}
{inline ? filePath || selectOrChange : selectOrChange}
{inline ? filePathWithNameOverride || selectOrChange : selectOrChange}
</Button>
{!inline && (

View File

@@ -82,22 +82,22 @@ export default function Settings({ hide }: Props) {
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-8 !py-4">
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-8 !py-4">
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-8 !py-4">
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-8 !py-4">
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-8 !py-4">
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-8 !py-4">
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
<SettingsLicense />
</TabContent>
</Tabs>

View File

@@ -242,7 +242,7 @@ function PluginSearch() {
defaultValue={query}
/>
</HStack>
<div className="w-full h-full overflow-y-auto">
<div className="w-full h-full">
{results.data == null ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
@@ -250,7 +250,7 @@ function PluginSearch() {
) : (results.data.plugins ?? []).length === 0 ? (
<EmptyStateText>No plugins found</EmptyStateText>
) : (
<Table>
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
@@ -282,7 +282,7 @@ function InstalledPlugins() {
</EmptyStateText>
</div>
) : (
<Table>
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>

View File

@@ -190,7 +190,7 @@ function InitializedTemplateFunctionDialog({
/>
)}
</div>
<div className="px-6 border-t border-t-border py-3 bg-surface-highlight w-full flex flex-col gap-4">
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== 'none' ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>

View File

@@ -88,15 +88,15 @@ export function Dialog({
{title}
</Heading>
) : (
<span aria-hidden />
<span />
)}
{description ? (
<div className="px-6 text-text-subtle mb-3" id={descriptionId}>
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span aria-hidden />
<span />
)}
<div

View File

@@ -92,11 +92,12 @@ function templateTags(
let option = options.find(
(o) => o.name === name || (o.type === 'function' && o.aliases?.includes(name)),
);
if (option == null) {
const from = node.from; // Cache here so the reference doesn't change
option = {
invalid: true,
type: 'variable',
invalid: true,
name: inner,
value: null,
label: inner,
@@ -106,20 +107,20 @@ function templateTags(
};
}
let invalid = false;
if (option.type === 'function') {
const tokens = parseTemplate(rawTag);
const values = collectArgumentValues(tokens, option);
for (const arg of option.args) {
if (!('optional' in arg)) continue;
if (!arg.optional && values[arg.name] == null) {
invalid = true;
// Clone so we don't mutate the original
option = { ...option, invalid: true };
break;
}
}
}
const widget = new TemplateTagWidget({ ...option, invalid }, rawTag, node.from);
const widget = new TemplateTagWidget(option, rawTag, node.from);
const deco = Decoration.replace({ widget, inclusive: true });
widgets.push(deco.range(node.from, node.to));
}

View File

@@ -9,6 +9,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { basename } from '@tauri-apps/api/path';
import classNames from 'classnames';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables';
@@ -70,6 +71,7 @@ export type Pair = {
name: string;
value: string;
contentType?: string;
filename?: string;
isFile?: boolean;
readOnlyName?: boolean;
};
@@ -492,6 +494,11 @@ export function PairEditorRow({
[onChange, pair],
);
const handleChangeValueFilename = useMemo(
() => (filename: string) => onChange?.({ ...pair, filename }),
[onChange, pair],
);
const handleEditMultiLineValue = useCallback(
() =>
showDialog({
@@ -614,6 +621,7 @@ export function PairEditorRow({
inline
size="xs"
filePath={pair.value}
nameOverride={pair.filename || null}
onChange={handleChangeValueFile}
/>
) : pair.value.includes('\n') ? (
@@ -659,6 +667,7 @@ export function PairEditorRow({
onChangeFile={handleChangeValueFile}
onChangeText={handleChangeValueText}
onChangeContentType={handleChangeValueContentType}
onChangeFilename={handleChangeValueFilename}
onDelete={handleDelete}
editMultiLine={handleEditMultiLineValue}
/>
@@ -687,6 +696,7 @@ function FileActionsDropdown({
onChangeFile,
onChangeText,
onChangeContentType,
onChangeFilename,
onDelete,
editMultiLine,
}: {
@@ -694,6 +704,7 @@ function FileActionsDropdown({
onChangeFile: ({ filePath }: { filePath: string | null }) => void;
onChangeText: (text: string) => void;
onChangeContentType: (contentType: string) => void;
onChangeFilename: (filename: string) => void;
onDelete: () => void;
editMultiLine: () => void;
}) {
@@ -731,6 +742,26 @@ function FileActionsDropdown({
onChangeContentType(contentType);
},
},
{
label: 'Set File Name',
leftSlot: <Icon icon="file_code" />,
onSelect: async () => {
console.log('PAIR', pair);
const defaultFilename = await basename(pair.value ?? '');
const filename = await showPrompt({
id: 'filename',
title: 'Override Filename',
label: 'Filename',
required: false,
placeholder: defaultFilename ?? 'myfile.png',
defaultValue: pair.filename,
confirmText: 'Set',
description: 'Leave blank to use the name of the selected file',
});
if (filename == null) return;
onChangeFilename(filename);
},
},
{
label: 'Unset File',
leftSlot: <Icon icon="x" />,
@@ -747,7 +778,17 @@ function FileActionsDropdown({
color: 'danger',
},
],
[editMultiLine, onChangeContentType, onChangeFile, onDelete, pair.contentType, pair.isFile],
[
editMultiLine,
onChangeContentType,
onChangeFile,
onDelete,
pair.contentType,
pair.isFile,
onChangeFilename,
pair.filename,
pair,
],
);
return (

View File

@@ -1,20 +1,50 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
export function Table({ children }: { children: ReactNode }) {
export function Table({
children,
className,
scrollable,
}: {
children: ReactNode;
className?: string;
scrollable?: boolean;
}) {
return (
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
{children}
</table>
<div className={classNames('w-full', scrollable && 'h-full overflow-y-auto')}>
<table
className={classNames(
className,
'w-full text-sm mb-auto min-w-full max-w-full',
'border-separate border-spacing-0',
scrollable && '[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10',
)}
>
{children}
</table>
</div>
);
}
export function TableBody({ children }: { children: ReactNode }) {
return <tbody className="divide-y divide-surface-highlight">{children}</tbody>;
return (
<tbody className="[&>tr:not(:last-child)>td]:border-b [&>tr:not(:last-child)>td]:border-b-surface-highlight">
{children}
</tbody>
);
}
export function TableHead({ children }: { children: ReactNode }) {
return <thead>{children}</thead>;
export function TableHead({ children, className }: { children: ReactNode; className?: string }) {
return (
<thead
className={classNames(
className,
'bg-surface [&_th]:border-b [&_th]:border-b-surface-highlight',
)}
>
{children}
</thead>
);
}
export function TableRow({ children }: { children: ReactNode }) {
@@ -42,9 +72,7 @@ export function TruncatedWideTableCell({
className?: string;
}) {
return (
<TableCell className={classNames(className, 'w-full relative')}>
<div className="absolute inset-0 py-2 truncate">{children}</div>
</TableCell>
<TableCell className={classNames(className, 'truncate max-w-0 w-full')}>{children}</TableCell>
);
}

View File

@@ -87,7 +87,7 @@ export function Tabs({
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
addBorders && layout === 'vertical' && 'ml-0 mb-2',
'flex items-center hide-scrollbars',
layout === 'horizontal' && 'h-full overflow-auto p-2 -mr-2',
layout === 'horizontal' && 'h-full overflow-auto p-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',

View File

@@ -107,6 +107,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
id: 'git-history',
size: 'md',
title: 'Commit History',
noPadding: true,
render: () => <HistoryDialog log={log.data ?? []} />,
});
},

View File

@@ -15,45 +15,43 @@ export function GitRemotesDialog({ dir }: Props) {
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
return (
<div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>URL</TableHeaderCell>
<TableHeaderCell>
<Button
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>URL</TableHeaderCell>
<TableHeaderCell>
<Button
className="text-text-subtle ml-auto"
size="2xs"
color="primary"
title="Add remote"
variant="border"
onClick={() => addGitRemote(dir)}
>
Add Remote
</Button>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{remotes.data?.map((r) => (
<TableRow key={r.name + r.url}>
<TableCell>{r.name}</TableCell>
<TableCell>{r.url}</TableCell>
<TableCell>
<IconButton
size="sm"
className="text-text-subtle ml-auto"
size="2xs"
color="primary"
title="Add remote"
variant="border"
onClick={() => addGitRemote(dir)}
>
Add Remote
</Button>
</TableHeaderCell>
icon="trash"
title="Remove remote"
onClick={() => rmRemote.mutate({ name: r.name })}
/>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{remotes.data?.map((r) => (
<TableRow key={r.name + r.url}>
<TableCell>{r.name}</TableCell>
<TableCell>{r.url}</TableCell>
<TableCell>
<IconButton
size="sm"
className="text-text-subtle ml-auto"
icon="trash"
title="Remove remote"
onClick={() => rmRemote.mutate({ name: r.name })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</TableBody>
</Table>
);
}

View File

@@ -16,29 +16,31 @@ interface Props {
export function HistoryDialog({ log }: Props) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Message</TableHeaderCell>
<TableHeaderCell>Author</TableHeaderCell>
<TableHeaderCell>When</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{log.map((l) => (
<TableRow key={l.author + (l.message ?? 'n/a') + l.when}>
<TruncatedWideTableCell>
{l.message || <em className="text-text-subtle">No message</em>}
</TruncatedWideTableCell>
<TableCell>
<span title={`Email: ${l.author.email}`}>{l.author.name || 'Unknown'}</span>
</TableCell>
<TableCell className="text-text-subtle">
<span title={l.when}>{formatDistanceToNowStrict(l.when)} ago</span>
</TableCell>
<div className="pl-5 pr-1 pb-1">
<Table scrollable className="px-1">
<TableHead>
<TableRow>
<TableHeaderCell>Message</TableHeaderCell>
<TableHeaderCell>Author</TableHeaderCell>
<TableHeaderCell>When</TableHeaderCell>
</TableRow>
))}
</TableBody>
</Table>
</TableHead>
<TableBody>
{log.map((l) => (
<TableRow key={l.author + (l.message ?? 'n/a') + l.when}>
<TruncatedWideTableCell>
{l.message || <em className="text-text-subtle">No message</em>}
</TruncatedWideTableCell>
<TableCell>
<span title={`Email: ${l.author.email}`}>{l.author.name || 'Unknown'}</span>
</TableCell>
<TableCell className="text-text-subtle">
<span title={l.when}>{formatDistanceToNowStrict(l.when)} ago</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -5,20 +5,21 @@ import { showDialog } from './dialog';
type ConfirmArgs = {
id: string;
} & Pick<DialogProps, 'title' | 'description'> &
} & Pick<DialogProps, 'title' | 'description' | 'size'> &
Pick<ConfirmProps, 'color' | 'confirmText' | 'requireTyping'>;
export async function showConfirm({
color,
confirmText,
requireTyping,
size = 'sm',
...extraProps
}: ConfirmArgs) {
return new Promise((onResult: ConfirmProps['onResult']) => {
showDialog({
...extraProps,
hideX: true,
size: 'sm',
size,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});

View File

@@ -18,11 +18,13 @@ export async function showPrompt({
description,
cancelText,
confirmText,
required,
...props
}: PromptArgs) {
const inputs: FormInput[] = [
{
...props,
optional: !required,
type: 'text',
name: 'value',
},