feat: add Files tab for browsing workspace files (#154)

* feat: add Files tab for browsing workspace files

- Add Files tab between Diff and Terminal in header
- Implement hierarchical file tree with expand/collapse
- Add fuzzy search with debouncing and relevance ranking
- Support gitignore filtering via `git check-ignore` (web + desktop)
- Add syntax highlighting for 150+ file types
- Add image preview (SVG, PNG, JPG, etc.)
- Add line numbers, wrap toggle, and copy button
- Desktop: split-pane layout matching DiffView
- Mobile: drill-in navigation with full-width sidebar
- Update help dialog with Cmd+3 shortcut
- Increase header breakpoint to 940px for new tab

* feat: enhance context and session stores to track agent/model/variant choices for historical sessions

* feat: implement line selection and commenting functionality in FilesView
This commit is contained in:
Bohdan Triapitsyn
2026-01-15 20:19:06 +02:00
committed by GitHub
parent ecf81c901d
commit 1be5dfda05
21 changed files with 2070 additions and 82 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { RiChat4Line, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { RiChat4Line, RiCodeLine, RiCommandLine, RiFolder6Line, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -266,6 +266,7 @@ export const Header: React.FC = () => {
icon: RiCodeLine,
badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined,
},
{ id: 'files', label: 'Files', icon: RiFolder6Line },
{ id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine },
{
id: 'git',
@@ -16,7 +16,7 @@ import { useDeviceInfo } from '@/lib/device';
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
import { cn } from '@/lib/utils';
import { ChatView, GitView, DiffView, TerminalView, SettingsView } from '@/components/views';
import { ChatView, GitView, DiffView, TerminalView, FilesView, SettingsView } from '@/components/views';
export const MainLayout: React.FC = () => {
const {
@@ -306,6 +306,8 @@ export const MainLayout: React.FC = () => {
return <DiffView />;
case 'terminal':
return <TerminalView />;
case 'files':
return <FilesView />;
default:
return null;
}
+7 -1
View File
@@ -17,6 +17,7 @@ import {
RiCloseCircleLine,
RiCodeLine,
RiCommandLine,
RiFolder6Line,
RiGitBranchLine,
RiLayoutLeftLine,
RiPaletteLine,
@@ -161,11 +162,16 @@ export const HelpDialog: React.FC = () => {
},
{
keys: [`${mod} + 3`],
description: "Open Files",
icon: RiFolder6Line,
},
{
keys: [`${mod} + 4`],
description: "Open Terminal",
icon: RiTerminalBoxLine,
},
{
keys: [`${mod} + 4`],
keys: [`${mod} + 5`],
description: "Open Git Panel",
icon: RiGitBranchLine,
},
+4 -2
View File
@@ -9,16 +9,18 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/30',
className
)}
style={{ width: '36px', height: '20px', minWidth: '36px', minHeight: '20px' }}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
style={{ width: '16px', height: '16px', minWidth: '16px', minHeight: '16px' }}
/>
</SwitchPrimitives.Root>
));
File diff suppressed because it is too large Load Diff
@@ -9,9 +9,11 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes';
import { flexokiThemeNames } from '@/lib/shiki/flexokiThemes';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel } from '@/lib/utils';
@@ -158,6 +160,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const sendMessage = useSessionStore(state => state.sendMessage);
const currentSessionId = useSessionStore(state => state.currentSessionId);
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore();
const getSessionAgentSelection = useContextStore(state => state.getSessionAgentSelection);
const getAgentModelForSession = useContextStore(state => state.getAgentModelForSession);
const getAgentModelVariantForSession = useContextStore(state => state.getAgentModelVariantForSession);
// Update main content center on resize
useEffect(() => {
@@ -210,6 +215,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
// Check if click is inside the comment UI portal
const commentUI = document.querySelector('[data-comment-ui]');
if (commentUI?.contains(target)) return;
// Check if click is inside toast (sonner)
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
// Check if click is on a line number (inside shadow DOM)
const path = e.composedPath();
@@ -239,10 +247,25 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const handleSendComment = useCallback(async () => {
if (!selection || !commentText.trim()) return;
if (!currentSessionId || !currentProviderId || !currentModelId) {
console.warn('Cannot send comment: no active session or model not selected');
if (!currentSessionId) {
toast.error('Select a session to send comment');
return;
}
// Get session-specific agent/model/variant with fallback to config values
const sessionAgent = getSessionAgentSelection(currentSessionId) || currentAgentName;
const sessionModel = sessionAgent ? getAgentModelForSession(currentSessionId, sessionAgent) : null;
const effectiveProviderId = sessionModel?.providerId || currentProviderId;
const effectiveModelId = sessionModel?.modelId || currentModelId;
if (!effectiveProviderId || !effectiveModelId) {
toast.error('Select a model to send comment');
return;
}
const effectiveVariant = sessionAgent && effectiveProviderId && effectiveModelId
? getAgentModelVariantForSession(currentSessionId, sessionAgent, effectiveProviderId, effectiveModelId) ?? currentVariant
: currentVariant;
const code = extractSelectedCode(original, modified, selection);
const startLine = selection.start;
@@ -259,18 +282,18 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
try {
await sendMessage(
message,
currentProviderId,
currentModelId,
currentAgentName,
effectiveProviderId,
effectiveModelId,
sessionAgent,
undefined,
undefined,
undefined,
currentVariant
effectiveVariant
);
} catch (e) {
console.error("Failed to send comment", e);
}
}, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab]);
}, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]);
ensureFlexokiThemesRegistered();
@@ -339,7 +362,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return (
<div
className="flex flex-col items-center gap-2 px-4"
style={{ width: 'min(100vw - 2rem, 42rem)' }}
style={{ width: 'min(100vw - 1rem, 42rem)' }}
>
<div className="w-full rounded-xl border bg-sidebar flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
{/* Textarea - auto-grows from 1 line to max 5 lines */}
@@ -45,7 +45,7 @@ const SETTINGS_SIDEBAR_MAX_WIDTH = 500;
const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 264;
// Width threshold for hiding tab labels (show icons only)
const TAB_LABELS_MIN_WIDTH = 700;
const TAB_LABELS_MIN_WIDTH = 940;
interface SettingsViewProps {
onClose?: () => void;
@@ -2,4 +2,5 @@ export { ChatView } from './ChatView';
export { GitView } from './GitView';
export { DiffView, useDiffFileCount } from './DiffView';
export { TerminalView } from './TerminalView';
export { FilesView } from './FilesView';
export { SettingsView } from './SettingsView';