Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/web-ui/src/api/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ import type {
LinkedFolderStatus,
} from '@/types/linkedFolders';
import type {RequestId} from './errors';
import type {
LinkedGitStatus,
LinkedGitReview,
LinkedGitReviewedFile,
LinkedGitCommit,
} from '@/types/linkedFolderGit';
import type {
LogEntry,
LogSettings,
Expand Down Expand Up @@ -1351,6 +1357,17 @@ export interface CommandMap {

/** Desktop only: linked folders are never routed through the web server. */
list_linked_folders: CommandDefinition<undefined, LinkedFolderStatus[], 'local-admin'>;
linked_git_status: CommandDefinition<{id: string}, LinkedGitStatus, 'local-admin'>;
linked_git_review: CommandDefinition<
{id: string; relativePath: string},
LinkedGitReview,
'local-admin'
>;
linked_git_commit: CommandDefinition<
{id: string; files: LinkedGitReviewedFile[]; message: string},
LinkedGitCommit,
'local-admin'
>;
link_local_folder: CommandDefinition<{path: string}, LinkedFolder, 'local-admin'>;
unlink_local_folder: CommandDefinition<{id: string}, void, 'local-admin'>;
list_linked_directory: CommandDefinition<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Folder,
FolderOpen,
FolderPlus,
GitBranch,
Loader2,
RefreshCw,
X,
Expand All @@ -24,6 +25,7 @@ import {
type LinkedListingState,
} from '@/utils/linkedFolders';
import type {LinkedFolderStatus} from '@/types/linkedFolders';
import {LinkedFolderGitModal} from '@/components/modals/LinkedFolderGitModal';

interface LinkedFoldersSectionProps {
/** Open linked files by `folderId:relativePath`; `dirty` marks unsaved edits. */
Expand All @@ -46,6 +48,7 @@ export function LinkedFoldersSection({openFiles, onOpenFile}: LinkedFoldersSecti
const unavailableReason = platform.negotiation.capabilities.linkLocalFolders;

const [folders, setFolders] = useState<LinkedFolderStatus[]>([]);
const [gitFolder, setGitFolder] = useState<LinkedFolderStatus | null>(null);
const [isLoading, setIsLoading] = useState(canLink);
const [loadError, setLoadError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
Expand Down Expand Up @@ -334,6 +337,22 @@ export function LinkedFoldersSection({openFiles, onOpenFile}: LinkedFoldersSecti
</span>
)}
</button>
{isRoot && !unavailable && (
<button
type="button"
className={iconButtonClassName}
aria-label={t('linkedGit.title')}
title={t('linkedGit.title')}
onClick={() =>
setGitFolder(
folders.find((folder) => folder.id === row.folderId) ??
null,
)
}
>
<GitBranch size={12} aria-hidden="true" />
</button>
)}
{isRoot && (
<button
type="button"
Expand All @@ -352,6 +371,13 @@ export function LinkedFoldersSection({openFiles, onOpenFile}: LinkedFoldersSecti
);
})}
</ul>
{gitFolder && (
<LinkedFolderGitModal
key={gitFolder.id}
folder={gitFolder}
onClose={() => setGitFolder(null)}
/>
)}
</div>
);
}
228 changes: 228 additions & 0 deletions packages/web-ui/src/components/modals/LinkedFolderGitModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import {useCallback, useEffect, useId, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {GitCommitHorizontal, Loader2} from 'lucide-react';
import {ThemeDialog} from '@/components/ui/ThemeDialog';
import {useTabularisClient} from '@/hooks/useTabularisClient';
import {usePlatformCapabilities} from '@/hooks/usePlatformCapabilities';
import {toErrorMessage} from '@/utils/errors';
import type {LinkedFolderStatus} from '@/types/linkedFolders';
import type {LinkedGitReview, LinkedGitStatus} from '@/types/linkedFolderGit';

const button =
'px-3 py-2 rounded-lg text-sm border border-default text-secondary hover:text-primary disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus';

export function LinkedFolderGitModal({
folder,
onClose,
}: {
folder: LinkedFolderStatus;
onClose: () => void;
}) {
const {t} = useTranslation();
const client = useTabularisClient();
const platform = usePlatformCapabilities();
const messageId = useId();
const [status, setStatus] = useState<LinkedGitStatus | null>(null);
const [reviews, setReviews] = useState<ReadonlyMap<string, LinkedGitReview>>(new Map());
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const [active, setActive] = useState<LinkedGitReview | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const available = platform.supports('linkLocalFolders');

const load = useCallback(async () => {
try {
if (!available) throw new Error(t('linkedGit.desktopOnly'));
const next = await client.call('linked_git_status', {id: folder.id});
setStatus(next);
setReviews(new Map());
setSelected(new Set());
setActive(null);
setError(null);
} catch (error) {
setError(toErrorMessage(error));
} finally {
setBusy(false);
}
}, [available, client, folder.id, t]);

useEffect(() => {
if (available) void load();
}, [available, load]);

const review = async (path: string) => {
setBusy(true);
setError(null);
try {
const next = await client.call('linked_git_review', {
id: folder.id,
relativePath: path,
});
setReviews((current) => new Map(current).set(path, next));
setActive(next);
} catch (error) {
setError(toErrorMessage(error));
} finally {
setBusy(false);
}
};

const commit = async () => {
if (!message.trim() || selected.size === 0 || busy) return;
const files = Array.from(selected).map((path) => ({path, token: reviews.get(path)!.token}));
setBusy(true);
setError(null);
try {
const committed = await client.call('linked_git_commit', {
id: folder.id,
files,
message,
});
setResult(t('linkedGit.committed', {commit: committed.commit.slice(0, 12)}));
setMessage('');
await load();
if (committed.warning) setError(committed.warning);
} catch (error) {
setError(toErrorMessage(error));
} finally {
setBusy(false);
}
};

return (
<ThemeDialog
isOpen
onClose={onClose}
busy={busy && available}
title={t('linkedGit.title')}
subtitle={folder.path}
icon={<GitCommitHorizontal size={20} />}
widthClass="w-[900px] max-w-[95vw]"
footer={
<>
<button
type="button"
className={button}
onClick={onClose}
disabled={busy && available}
>
{t('common.close')}
</button>
<button
type="button"
className={`${button} bg-accent-primary text-inverse`}
onClick={() => void commit()}
disabled={!available || busy || !message.trim() || selected.size === 0}
>
{busy && available && (
<Loader2
size={14}
className="inline animate-spin mr-2"
aria-hidden="true"
/>
)}
{t('linkedGit.commit')}
</button>
</>
}
>
<p className="text-xs text-muted mb-3">{t('linkedGit.diskOnly')}</p>
{!available && <p role="status">{t('linkedGit.desktopOnly')}</p>}
{error && (
<p role="alert" className="text-sm text-accent-error whitespace-pre-wrap mb-3">
{error}
</p>
)}
{result && (
<p role="status" className="text-sm text-accent-success mb-3">
{result}
</p>
)}
{available && (
<>
<div className="flex items-center gap-2 mb-3">
<p className="text-xs text-muted break-all flex-1">{status?.repository}</p>
<button
type="button"
className={button}
disabled={busy}
onClick={() => {
setBusy(true);
void load();
}}
>
{t('linkedGit.refresh')}
</button>
</div>
<p className="text-xs text-muted mb-2">{t('linkedGit.renames')}</p>
<div className="grid grid-cols-1 md:grid-cols-[minmax(180px,1fr)_2fr] gap-3">
<div className="border border-default rounded-lg overflow-auto max-h-72">
{status?.files.length === 0 && (
<p className="p-3 text-sm text-muted">{t('linkedGit.empty')}</p>
)}
<ul aria-label={t('linkedGit.files')}>
{status?.files.map((file) => (
<li
key={file.path}
className="flex items-center gap-2 p-2 border-b border-default"
>
<input
type="checkbox"
checked={selected.has(file.path)}
disabled={busy || !reviews.has(file.path)}
aria-label={t('linkedGit.include', {path: file.path})}
onChange={(event) => {
const checked = event.target.checked;
setSelected((current) => {
const next = new Set(current);
if (checked) next.add(file.path);
else next.delete(file.path);
return next;
});
}}
/>
<span className="font-mono text-xs text-muted whitespace-pre">
{file.status}
</span>
<button
type="button"
className={`${button} min-w-0 flex-1 break-all text-left`}
disabled={busy}
aria-label={t('linkedGit.review', {path: file.path})}
aria-pressed={active?.path === file.path}
onClick={() => void review(file.path)}
>
{file.path}
</button>
</li>
))}
</ul>
</div>
<textarea
readOnly
rows={12}
wrap="off"
value={active?.diff ?? t('linkedGit.choose')}
aria-label={t('linkedGit.diff')}
className="border border-default rounded-lg p-3 overflow-auto max-h-72 text-xs font-mono text-primary bg-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
/>
</div>
<label htmlFor={messageId} className="block text-sm text-primary mt-4 mb-1">
{t('linkedGit.message')}
</label>
<textarea
id={messageId}
value={message}
onChange={(event) => setMessage(event.target.value)}
maxLength={16384}
rows={2}
disabled={busy}
className="w-full rounded-lg p-2 bg-base border border-default text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
/>
</>
)}
</ThemeDialog>
);
}
16 changes: 16 additions & 0 deletions packages/web-ui/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -3200,6 +3200,22 @@
"importErrors": { "unrecognized": "Kein erkanntes VS-Code-Design.", "modeRequired": "Modus auswählen und Vorschau erneut öffnen.", "includeOnly": "Reine Include-Designs sind nicht importierbar. Die tatsächlichen Farben bereitstellen.", "noSupportedData": "Keine unterstützten Farben oder SQL-Tokenregeln gefunden.", "invalidColor": "Ungültiger Wert einer unterstützten Farbe.", "invalidRules": "Ungültige oder zu viele Tokenregeln." },
"diagnostics": { "unsupportedKey": "Nicht unterstütztes Feld", "unsupportedColor": "Nicht unterstützte Farbe", "unsupportedScope": "Nicht unterstützter Bereich", "scopeApproximation": "Angenäherte SQL-Zuordnung", "unsupportedStyle": "Nicht unterstützter Stil", "ignoredInclude": "Include nicht geladen", "ignoredSemanticTokens": "Semantische Tokens ignoriert", "unsupportedReference": "Externe Referenz nicht geladen", "unsupportedSetting": "Nicht unterstützte Einstellung", "tokenAlphaFlattened": "Token-Deckkraft gegen den Basishintergrund berechnet", "modeApproximation": "Modus überschrieben oder angenähert", "diagnosticsTruncated": "Weitere Diagnosen ausgelassen" }
},
"linkedGit": {
"title": "Git-Änderungen prüfen",
"diskOnly": "Commits verwenden gespeicherte Dateien auf der Platte. Ungespeicherte Editor-Entwürfe werden nicht einbezogen. Nur markierte, geprüfte Skripte werden committet.",
"desktopOnly": "Git für verknüpfte SQL-Ordner ist nur in der Desktop-App verfügbar.",
"refresh": "Änderungen aktualisieren",
"commit": "Ausgewählte Skripte committen",
"committed": "Commit {{commit}} erstellt.",
"renames": "M geändert · A hinzugefügt · D gelöscht · ? nicht versioniert. Umbenennungen erscheinen als Löschung und neue Datei; beide prüfen.",
"empty": "Keine geänderten SQL-Skripte.",
"files": "Geänderte SQL-Skripte",
"include": "{{path}} in Commit aufnehmen",
"review": "{{path}} prüfen",
"diff": "Geprüfter Datei-Diff",
"choose": "Wähle ein Skript und prüfe den Diff, bevor du es aufnimmst.",
"message": "Commit-Nachricht"
},
"aiInline": {
"title": "KI-Inline-Vervollständigung",
"enable": "SQL-Vorschläge beim Tippen",
Expand Down
16 changes: 16 additions & 0 deletions packages/web-ui/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3336,6 +3336,22 @@
"copied": "Copied to clipboard",
"copyFailed": "Could not copy to the clipboard. Try again."
},
"linkedGit": {
"title": "Review Git changes",
"diskOnly": "Commits use saved files on disk. Unsaved editor drafts are not included. Only checked, reviewed scripts will be committed.",
"desktopOnly": "Git for linked SQL folders is available in the desktop app only.",
"refresh": "Refresh changes",
"commit": "Commit selected scripts",
"committed": "Created commit {{commit}}.",
"renames": "M modified · A added · D deleted · ? untracked. Renames appear as a deletion and an addition; review both.",
"empty": "No changed SQL scripts.",
"files": "Changed SQL scripts",
"include": "Include {{path}} in commit",
"review": "Review {{path}}",
"diff": "Reviewed file diff",
"choose": "Select a script to review its diff before including it.",
"message": "Commit message"
},
"aiInline": {
"title": "AI inline completion",
"enable": "Suggest SQL while typing",
Expand Down
16 changes: 16 additions & 0 deletions packages/web-ui/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -3134,6 +3134,22 @@
"importErrors": { "unrecognized": "Tema de VS Code no reconocido.", "modeRequired": "Elija un modo y repita la vista previa.", "includeOnly": "No se pueden importar temas con solo referencias include. Proporcione los colores reales.", "noSupportedData": "No se encontraron colores o reglas SQL compatibles.", "invalidColor": "Un color compatible tiene un valor no válido.", "invalidRules": "Reglas de tokens no válidas o excesivas." },
"diagnostics": { "unsupportedKey": "Campo no compatible", "unsupportedColor": "Color no compatible", "unsupportedScope": "Ámbito no compatible", "scopeApproximation": "Asignación SQL aproximada", "unsupportedStyle": "Estilo no compatible", "ignoredInclude": "Include no cargado", "ignoredSemanticTokens": "Tokens semánticos ignorados", "unsupportedReference": "Referencia externa no cargada", "unsupportedSetting": "Ajuste no compatible", "tokenAlphaFlattened": "Opacidad de tokens calculada sobre el fondo base", "modeApproximation": "Modo sustituido o aproximado", "diagnosticsTruncated": "Se omitieron diagnósticos adicionales" }
},
"linkedGit": {
"title": "Revisar cambios de Git",
"diskOnly": "Los commits usan archivos guardados en disco. No incluyen borradores sin guardar. Solo se incluirán los scripts marcados y revisados.",
"desktopOnly": "Git para carpetas SQL vinculadas solo está disponible en la aplicación de escritorio.",
"refresh": "Actualizar cambios",
"commit": "Crear commit de scripts seleccionados",
"committed": "Commit {{commit}} creado.",
"renames": "M modificado · A añadido · D eliminado · ? sin seguimiento. Los renombrados aparecen como eliminación y adición; revisa ambos.",
"empty": "No hay scripts SQL modificados.",
"files": "Scripts SQL modificados",
"include": "Incluir {{path}} en el commit",
"review": "Revisar {{path}}",
"diff": "Diferencias del archivo revisado",
"choose": "Selecciona un script para revisar sus diferencias antes de incluirlo.",
"message": "Mensaje del commit"
},
"aiInline": {
"title": "Completado de IA en línea",
"enable": "Sugerir SQL al escribir",
Expand Down
Loading