feat(ui): redesign workspace shell with context panel, tabbed sidebars, and faster diff UX (#433)

* feat: tabbed right sidebar, context panel, floating diff comments

* fix: auto-close left sidebar when context panel opens

- Increase default context panel width from 520 to 600 pixels
- Increase sidebar minimum width from 200 to 300 pixels
- Replace collapsible component with custom button in diff view

* refactoring: rework sidebars, tabs, and file tree layout

- Rewrite AnimatedTabs as segment-style with sliding indicator
- Upgrade SidebarFilesTree to match FilesView features (context menus,
  git status, file icons, CRUD dialogs, fuzzy search ranking)
- Restructure FilesView header: tabs row + actions row, remove breadcrumbs
- Show relative path in context panel header, track active tab
- Allow left sidebar to stay open alongside context panel
- Hide diff/files tabs from header on desktop (mobile-only)
- Move chevron after group name in session sidebar
- Compact tab heights in right sidebar and git view
- Size PreviewToggleButton to match other action buttons
- Remove directory loading spinner from folder icons

* feat: add project icon and color customization

- Enable users to assign custom icons to projects
- Allow users to choose accent colors for projects
- Stabilize repo status UI during project switching

* feat: add scroll fade indicators to editor tabs

* style: reduce spacing and icon sizes in header

* style: adjust tab component padding from uniform to vertical-horizontal

* feat: Add session state indicators to project tabs

* feat: Enhance session status handling and improve UI responsiveness

* fix: preserve upstream tracking on branch rename

* fix: improve initial remote selection for pull requests

- Uses saved remote name from previous session when available
- Selects remote based on tracking branch when possible
- Falls back to origin or first available remote

* perf(diff): faster highlight, stable stacked scroll

- split/unified Pierre worker pools; prefer shiki-wasm
- align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks
- harden stacked pin/align (cancel on user scroll/input); prevent overscroll
- make overlay scrollbar MutationObserver optional; disable for diff container

* feat: handle binary files in diff view

* fix: adjust project tabs layout and drag regions

* style: update drag overlay visual styling

* feat: enable number keys to switch projects in the sidebar

* fix: recognize octet-stream as text-based MIME type

* feat: add keyboard navigation to context panel

* feat: add session pinning to sidebar

- Pin important sessions to keep them at the top
- Pinned sessions persist across browser sessions

* refactor: move context usage display from chat input to header
This commit is contained in:
Bohdan Triapitsyn
2026-02-16 14:15:19 +02:00
committed by GitHub
parent 12606b9e53
commit 47c943b487
42 changed files with 4874 additions and 1163 deletions
+112
View File
@@ -1316,6 +1316,8 @@ export async function getRangeFiles(directory, { base, head } = {}) {
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
const BINARY_SNIFF_BYTES = 8192;
function isImageFile(filePath) {
const ext = filePath.split('.').pop()?.toLowerCase();
return IMAGE_EXTENSIONS.includes(ext || '');
@@ -1337,6 +1339,67 @@ function getImageMimeType(filePath) {
return mimeMap[ext] || 'application/octet-stream';
}
const parseIsBinaryFromNumstat = (raw) => {
const text = String(raw || '').trim();
if (!text) {
return false;
}
// Expected format: <added>\t<deleted>\t<path>
const firstLine = text.split('\n').map((line) => line.trim()).find(Boolean) || '';
const [added, deleted] = firstLine.split('\t');
return added === '-' || deleted === '-';
};
const looksBinaryBySniff = async (absolutePath) => {
try {
const handle = await fsp.open(absolutePath, 'r');
try {
const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
const { bytesRead } = await handle.read(buffer, 0, BINARY_SNIFF_BYTES, 0);
if (bytesRead <= 0) {
return false;
}
return buffer.subarray(0, bytesRead).includes(0);
} finally {
await handle.close();
}
} catch {
return false;
}
};
const isBinaryDiff = async (directoryPath, filePath, staged) => {
// Fast path: ask git for numstat. For binary, it returns "-\t-\t<path>".
const args = ['diff', '--numstat'];
if (staged) {
args.push('--cached');
}
args.push('--', filePath);
const result = await runGitCommand(directoryPath, args);
if (parseIsBinaryFromNumstat(result.stdout)) {
return true;
}
// Fallback for untracked files (diff output is empty): use --no-index against /dev/null
if (!staged) {
const tracked = await runGitCommand(directoryPath, ['ls-files', '--error-unmatch', '--', filePath]).then((r) => r.success);
if (!tracked) {
const noIndex = await runGitCommand(directoryPath, ['diff', '--no-index', '--numstat', '--', '/dev/null', filePath]);
if (parseIsBinaryFromNumstat(noIndex.stdout) || parseIsBinaryFromNumstat(noIndex.stderr) || parseIsBinaryFromNumstat(noIndex.message)) {
return true;
}
const text = `${noIndex.stdout || ''}\n${noIndex.stderr || ''}\n${noIndex.message || ''}`.toLowerCase();
if (text.includes('binary files') || text.includes('git binary patch')) {
return true;
}
}
}
return false;
};
export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
if (!directory || !filePath) {
throw new Error('directory and path are required for getFileDiff');
@@ -1347,6 +1410,20 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
const isImage = isImageFile(filePath);
const mimeType = isImage ? getImageMimeType(filePath) : null;
if (!isImage) {
const absolutePath = path.join(directoryPath, filePath);
const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
const isBinary = isBinaryBySniff || (await isBinaryDiff(directoryPath, filePath, staged));
if (isBinary) {
return {
original: '',
modified: '',
path: filePath,
isBinary: true,
};
}
}
let original = '';
try {
if (isImage) {
@@ -1396,6 +1473,7 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
original,
modified,
path: filePath,
isBinary: false,
};
}
@@ -2381,8 +2459,42 @@ export async function renameBranch(directory, oldName, newName) {
const git = await createGit(directory);
try {
const normalizedOldName = cleanBranchName(String(oldName || '').trim());
const normalizedNewName = cleanBranchName(String(newName || '').trim());
const previousRemote = await git
.raw(['config', '--get', `branch.${normalizedOldName}.remote`])
.then((value) => String(value || '').trim())
.catch(() => '');
const previousMerge = await git
.raw(['config', '--get', `branch.${normalizedOldName}.merge`])
.then((value) => String(value || '').trim())
.catch(() => '');
// Use git branch -m command to rename the branch
await git.raw(['branch', '-m', oldName, newName]);
if (previousRemote && previousMerge && normalizedNewName) {
const previousMergeBranch = cleanBranchName(previousMerge);
const nextMergeBranch =
previousMergeBranch === normalizedOldName
? normalizedNewName
: previousMergeBranch;
const upstream = normalizeUpstreamTarget(previousRemote, nextMergeBranch);
if (upstream) {
try {
await runGitCommandOrThrow(
directory,
['branch', `--set-upstream-to=${upstream.full}`, normalizedNewName],
`Failed to set upstream to ${upstream.full}`
);
} catch {
await setBranchTrackingFallback(directory, normalizedNewName, upstream);
}
}
}
return { success: true, branch: newName };
} catch (error) {
console.error('Failed to rename branch:', error);