WIP: uncommitted changes before rebase (#168)

This commit is contained in:
Jinjing 2026-03-28 11:59:55 -07:00 committed by GitHub
parent cf0565ab97
commit e699bec46e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1455 additions and 16 deletions

View file

@ -48,6 +48,10 @@
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
"radix-ui": "^1.4.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.0",
"simple-git": "^3.33.0",
"sonner": "^2.0.7",

File diff suppressed because it is too large Load diff

View file

@ -424,6 +424,141 @@
transform: translateY(0);
}
/* ── Markdown Preview ────────────────────────────────── */
.markdown-preview {
padding: 24px 32px;
font-size: 14px;
line-height: 1.7;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
.markdown-body h1 {
font-size: 1.75em;
}
.markdown-body h2 {
font-size: 1.4em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.3em;
}
.markdown-body h3 {
font-size: 1.15em;
}
.markdown-body p {
margin: 0.75em 0;
}
.markdown-body a {
color: var(--primary);
text-decoration: underline;
}
.markdown-body code {
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: var(--font-mono, monospace);
}
.markdown-dark .markdown-body code {
background: rgba(255, 255, 255, 0.08);
}
.markdown-light .markdown-body code {
background: rgba(0, 0, 0, 0.06);
}
.markdown-body pre {
margin: 1em 0;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
line-height: 1.5;
}
.markdown-dark .markdown-body pre {
background: rgba(255, 255, 255, 0.06);
}
.markdown-light .markdown-body pre {
background: rgba(0, 0, 0, 0.04);
}
.markdown-body pre code {
padding: 0;
background: none;
font-size: 0.85em;
}
.markdown-body ul,
.markdown-body ol {
margin: 0.75em 0;
padding-left: 1.5em;
}
.markdown-body li {
margin: 0.25em 0;
}
.markdown-body li > input[type='checkbox'] {
margin-right: 0.5em;
}
.markdown-body blockquote {
margin: 0.75em 0;
padding: 0.25em 1em;
border-left: 3px solid var(--border);
color: var(--muted-foreground);
}
.markdown-body table {
margin: 1em 0;
border-collapse: collapse;
width: 100%;
}
.markdown-body th,
.markdown-body td {
padding: 6px 12px;
border: 1px solid var(--border);
text-align: left;
}
.markdown-body th {
font-weight: 600;
}
.markdown-dark .markdown-body th {
background: rgba(255, 255, 255, 0.04);
}
.markdown-light .markdown-body th {
background: rgba(0, 0, 0, 0.03);
}
.markdown-body hr {
margin: 1.5em 0;
border: none;
border-top: 1px solid var(--border);
}
.markdown-body img {
max-width: 100%;
border-radius: 4px;
}
/* ── Content ─────────────────────────────────────────── */
.content-area {

View file

@ -4,10 +4,13 @@ import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { getEditorHeaderCopyState } from './editor-header'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { MarkdownViewMode } from '@/store/slices/editor'
import MarkdownViewToggle from './MarkdownViewToggle'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer'))
const MarkdownPreview = lazy(() => import('./MarkdownPreview'))
type FileContent = {
content: string
@ -25,6 +28,9 @@ export default function EditorPanel(): React.JSX.Element | null {
const markFileDirty = useAppStore((s) => s.markFileDirty)
const pendingEditorReveal = useAppStore((s) => s.pendingEditorReveal)
const markdownViewMode = useAppStore((s) => s.markdownViewMode)
const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode)
const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null
const [fileContents, setFileContents] = useState<Record<string, FileContent>>({})
@ -244,12 +250,42 @@ export default function EditorPanel(): React.JSX.Element | null {
? detectLanguage(activeFile.relativePath)
: detectLanguage(activeFile.filePath)
const isMarkdown = resolvedLanguage === 'markdown'
const mdViewMode: MarkdownViewMode =
isMarkdown && activeFile.mode === 'edit'
? (markdownViewMode[activeFile.id] ?? 'source')
: 'source'
const loadingFallback = (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading editor...
</div>
)
const renderMonacoEditor = (fc: FileContent): React.JSX.Element => (
<MonacoEditor
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
content={editBuffers[activeFile.id] ?? fc.content}
language={resolvedLanguage}
onContentChange={handleContentChange}
onSave={handleSave}
revealLine={pendingEditorReveal?.line}
revealColumn={pendingEditorReveal?.column}
revealMatchLength={pendingEditorReveal?.matchLength}
/>
)
const renderMarkdownContent = (fc: FileContent): React.JSX.Element => {
const currentContent = editBuffers[activeFile.id] ?? fc.content
if (mdViewMode === 'preview') {
return <MarkdownPreview content={currentContent} filePath={activeFile.filePath} />
}
return renderMonacoEditor(fc)
}
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div className="editor-header">
@ -288,6 +324,12 @@ export default function EditorPanel(): React.JSX.Element | null {
</Tooltip>
</TooltipProvider>
)}
{isMarkdown && activeFile.mode === 'edit' && (
<MarkdownViewToggle
mode={mdViewMode}
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
/>
)}
</div>
<Suspense fallback={loadingFallback}>
{isCombinedDiff ? (
@ -309,19 +351,7 @@ export default function EditorPanel(): React.JSX.Element | null {
</div>
)
}
return (
<MonacoEditor
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
content={editBuffers[activeFile.id] ?? fc.content}
language={resolvedLanguage}
onContentChange={handleContentChange}
onSave={handleSave}
revealLine={pendingEditorReveal?.line}
revealColumn={pendingEditorReveal?.column}
revealMatchLength={pendingEditorReveal?.matchLength}
/>
)
return isMarkdown ? renderMarkdownContent(fc) : renderMonacoEditor(fc)
})()
) : (
(() => {

View file

@ -0,0 +1,81 @@
import React from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
import rehypeHighlight from 'rehype-highlight'
import type { Components } from 'react-markdown'
import { useAppStore } from '@/store'
import { getMarkdownPreviewImageSrc, getMarkdownPreviewLinkTarget } from './markdown-preview-links'
type MarkdownPreviewProps = {
content: string
filePath: string
}
export default function MarkdownPreview({
content,
filePath
}: MarkdownPreviewProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const components: Components = {
a: ({ href, children, ...props }) => {
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
if (!href || href.startsWith('#')) {
return
}
event.preventDefault()
const target = getMarkdownPreviewLinkTarget(href, filePath)
if (!target) {
return
}
let parsed: URL
try {
parsed = new URL(target)
} catch {
return
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
void window.api.shell.openUrl(parsed.toString())
return
}
if (parsed.protocol === 'file:') {
void window.api.shell.openFileUri(parsed.toString())
}
}
return (
<a {...props} href={href} onClick={handleClick}>
{children}
</a>
)
},
img: ({ src, alt, ...props }) => (
<img {...props} src={getMarkdownPreviewImageSrc(src, filePath)} alt={alt ?? ''} />
)
}
return (
<div
className={`markdown-preview flex-1 min-h-0 overflow-auto ${isDark ? 'markdown-dark' : 'markdown-light'}`}
>
<div className="markdown-body">
<Markdown
components={components}
remarkPlugins={[remarkGfm, remarkFrontmatter]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</Markdown>
</div>
</div>
)
}

View file

@ -0,0 +1,36 @@
import React from 'react'
import { Code, BookOpen } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { MarkdownViewMode } from '@/store/slices/editor'
type MarkdownViewToggleProps = {
mode: MarkdownViewMode
onChange: (mode: MarkdownViewMode) => void
}
export default function MarkdownViewToggle({
mode,
onChange
}: MarkdownViewToggleProps): React.JSX.Element {
return (
<ToggleGroup
type="single"
size="sm"
className="h-6 [&_[data-slot=toggle-group-item]]:h-7 [&_[data-slot=toggle-group-item]]:min-w-5 [&_[data-slot=toggle-group-item]]:px-2.5"
variant="outline"
value={mode}
onValueChange={(v) => {
if (v) {
onChange(v as MarkdownViewMode)
}
}}
>
<ToggleGroupItem value="source" aria-label="Source" title="Source">
<Code className="h-2 w-2" />
</ToggleGroupItem>
<ToggleGroupItem value="preview" aria-label="Preview" title="Preview">
<BookOpen className="h-2 w-2" />
</ToggleGroupItem>
</ToggleGroup>
)
}

View file

@ -158,7 +158,7 @@ export default function MonacoEditor({
onChange={handleChange}
onMount={handleMount}
options={{
minimap: { enabled: true },
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: settings?.terminalFontSize ?? 13,

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getMarkdownPreviewImageSrc, getMarkdownPreviewLinkTarget } from './markdown-preview-links'
describe('getMarkdownPreviewLinkTarget', () => {
it('resolves relative markdown links against the current file', () => {
expect(getMarkdownPreviewLinkTarget('./guide/setup.md', '/repo/docs/README.md')).toBe(
'file:///repo/docs/guide/setup.md'
)
})
it('preserves external links', () => {
expect(getMarkdownPreviewLinkTarget('https://example.com/docs', '/repo/docs/README.md')).toBe(
'https://example.com/docs'
)
})
it('does not hijack hash-only anchors', () => {
expect(getMarkdownPreviewLinkTarget('#overview', '/repo/docs/README.md')).toBeNull()
})
})
describe('getMarkdownPreviewImageSrc', () => {
it('resolves relative image paths against the current file', () => {
expect(getMarkdownPreviewImageSrc('../assets/diagram.png', '/repo/docs/guides/README.md')).toBe(
'file:///repo/docs/assets/diagram.png'
)
})
it('resolves relative paths for Windows markdown files', () => {
expect(getMarkdownPreviewImageSrc('./diagram.png', 'C:\\repo\\docs\\README.md')).toBe(
'file:///C:/repo/docs/diagram.png'
)
})
it('leaves unsupported schemes unchanged', () => {
expect(getMarkdownPreviewImageSrc('data:image/png;base64,abc', '/repo/docs/README.md')).toBe(
'data:image/png;base64,abc'
)
})
})

View file

@ -0,0 +1,75 @@
function toFileUrl(filePath: string): string {
const normalizedPath = filePath.replaceAll('\\', '/')
const segments = normalizedPath.split('/').map((segment, index) => {
if (index === 0 && /^[A-Za-z]:$/.test(segment)) {
return segment
}
return encodeURIComponent(segment)
})
if (normalizedPath.startsWith('/')) {
return `file://${segments.join('/')}`
}
return `file:///${segments.join('/')}`
}
function resolveMarkdownUrl(rawUrl: string, filePath: string): URL | null {
if (!rawUrl || rawUrl.startsWith('#')) {
return null
}
try {
return new URL(rawUrl, toFileUrl(filePath))
} catch {
return null
}
}
export function getMarkdownPreviewLinkTarget(
rawHref: string | undefined,
filePath: string
): string | null {
if (!rawHref) {
return null
}
const resolved = resolveMarkdownUrl(rawHref, filePath)
if (!resolved) {
return null
}
if (
resolved.protocol === 'http:' ||
resolved.protocol === 'https:' ||
resolved.protocol === 'file:'
) {
return resolved.toString()
}
return null
}
export function getMarkdownPreviewImageSrc(
rawSrc: string | undefined,
filePath: string
): string | undefined {
if (!rawSrc) {
return rawSrc
}
const resolved = resolveMarkdownUrl(rawSrc, filePath)
if (!resolved) {
return rawSrc
}
if (
resolved.protocol === 'http:' ||
resolved.protocol === 'https:' ||
resolved.protocol === 'file:'
) {
return resolved.toString()
}
return rawSrc
}

View file

@ -87,3 +87,40 @@ describe('createEditorSlice openDiff', () => {
expect(store.getState().activeFileId).toBe('/repo/file.ts::staged')
})
})
describe('createEditorSlice markdown preview state', () => {
it('drops markdown view mode for a replaced preview tab', () => {
const store = createEditorStore()
store.getState().openFile(
{
filePath: '/repo/docs/README.md',
relativePath: 'docs/README.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
},
{ preview: true }
)
store.getState().setMarkdownViewMode('/repo/docs/README.md', 'preview')
store.getState().openFile(
{
filePath: '/repo/docs/guide.md',
relativePath: 'docs/guide.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
},
{ preview: true }
)
expect(store.getState().markdownViewMode).toEqual({})
expect(store.getState().openFiles).toEqual([
expect.objectContaining({
id: '/repo/docs/guide.md',
isPreview: true
})
])
})
})

View file

@ -18,7 +18,13 @@ export type OpenFile = {
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks'
export type ActivityBarPosition = 'top' | 'side'
export type MarkdownViewMode = 'source' | 'preview'
export type EditorSlice = {
// Markdown view mode per file (fileId -> mode)
markdownViewMode: Record<string, MarkdownViewMode>
setMarkdownViewMode: (fileId: string, mode: MarkdownViewMode) => void
// Right sidebar
rightSidebarOpen: boolean
rightSidebarWidth: number
@ -94,6 +100,13 @@ export type EditorSlice = {
}
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set) => ({
// Markdown view mode
markdownViewMode: {},
setMarkdownViewMode: (fileId, mode) =>
set((s) => ({
markdownViewMode: { ...s.markdownViewMode, [fileId]: mode }
})),
// Right sidebar
rightSidebarOpen: false,
rightSidebarWidth: 280,
@ -177,11 +190,24 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
(f) => f.worktreeId === worktreeId && f.isPreview
)
if (existingPreviewIdx !== -1) {
const replacedPreview = s.openFiles[existingPreviewIdx]
const nextMarkdownViewMode =
replacedPreview.id === id
? s.markdownViewMode
: Object.fromEntries(
Object.entries(s.markdownViewMode).filter(
([fileId]) => fileId !== replacedPreview.id
)
)
// Replace in-place to preserve tab position
newFiles = s.openFiles.map((f, i) =>
i === existingPreviewIdx ? { ...file, id, isDirty: false, isPreview: true } : f
)
return { openFiles: newFiles, ...activeResult }
return {
openFiles: newFiles,
markdownViewMode: nextMarkdownViewMode,
...activeResult
}
}
}
@ -210,6 +236,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const closedFile = s.openFiles.find((f) => f.id === fileId)
const idx = s.openFiles.findIndex((f) => f.id === fileId)
const newFiles = s.openFiles.filter((f) => f.id !== fileId)
const newMarkdownViewMode = { ...s.markdownViewMode }
delete newMarkdownViewMode[fileId]
let newActiveId = s.activeFileId
const newActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
@ -255,6 +283,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeTabType: newActiveTabType,
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
markdownViewMode: newMarkdownViewMode,
pendingEditorReveal: null
}
}),
@ -263,10 +292,19 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
set((s) => {
const activeWorktreeId = s.activeWorktreeId
if (!activeWorktreeId) {
return { openFiles: [], activeFileId: null, activeTabType: 'terminal' }
return {
openFiles: [],
activeFileId: null,
activeTabType: 'terminal',
markdownViewMode: {}
}
}
// Only close files for the current worktree
const newFiles = s.openFiles.filter((f) => f.worktreeId !== activeWorktreeId)
const remainingFileIds = new Set(newFiles.map((f) => f.id))
const newMarkdownViewMode = Object.fromEntries(
Object.entries(s.markdownViewMode).filter(([fileId]) => remainingFileIds.has(fileId))
)
const newActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
delete newActiveFileIdByWorktree[activeWorktreeId]
const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
@ -275,6 +313,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
openFiles: newFiles,
activeFileId: null,
activeTabType: 'terminal',
markdownViewMode: newMarkdownViewMode,
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree
}