Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1c2defc
feat(app): add context menu to file tree with open and mention actions
alexyaroshuk Feb 7, 2026
09a37a7
add context menu to file tabs, close others option, add localization …
alexyaroshuk Feb 7, 2026
afe7aa6
Merge remote-tracking branch 'upstream/dev' into feat/file-tree-conte…
alexyaroshuk Feb 7, 2026
0bf2fe7
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 7, 2026
7b5d780
Merge branch 'dev-clean' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 7, 2026
35315ec
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 7, 2026
a286c7f
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 8, 2026
0b8d574
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 9, 2026
8957801
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 9, 2026
9d38c38
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 9, 2026
031c6b3
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 9, 2026
dadbfe0
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 9, 2026
48997db
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 9, 2026
7c5779c
ssr-safe context menu in file tree to fix tests
alexyaroshuk Feb 9, 2026
d06a84a
Merge branch 'feat/file-tree-context-menu-clean' of https://github.co…
alexyaroshuk Feb 9, 2026
b15c2c3
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 9, 2026
b8310ea
Merge branch 'dev' into feat/file-tree-context-menu-clean
alexyaroshuk Feb 10, 2026
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
86 changes: 70 additions & 16 deletions packages/app/src/components/file-tree.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import {
createEffect,
createMemo,
createResource,
For,
Match,
on,
Expand All @@ -16,7 +18,7 @@ import {
type ComponentProps,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { Dynamic, isServer } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"

function pathToFileUrl(filepath: string): string {
Expand All @@ -27,6 +29,43 @@ function pathToFileUrl(filepath: string): string {
return `file://${encodedPath}`
}

function FileContextMenu(
props: ParentProps<{ onMention?: () => void; onOpen?: () => void; mentionLabel?: string; openLabel?: string }>,
) {
if (isServer) return props.children

const [mod] = createResource(async () => {
const m = await import("@opencode-ai/ui/context-menu")
return m.ContextMenu
})

return (
<Show when={mod()}>
{(ContextMenu) => (
<Dynamic component={ContextMenu()}>
<Dynamic component={ContextMenu().Trigger} as="div">
{props.children}
</Dynamic>
<Dynamic component={ContextMenu().Portal}>
<Dynamic component={ContextMenu().Content}>
<Show when={props.onOpen}>
<Dynamic component={ContextMenu().Item} onSelect={props.onOpen}>
<Dynamic component={ContextMenu().ItemLabel}>{props.openLabel}</Dynamic>
</Dynamic>
</Show>
<Show when={props.onMention}>
<Dynamic component={ContextMenu().Item} onSelect={props.onMention}>
<Dynamic component={ContextMenu().ItemLabel}>{props.mentionLabel}</Dynamic>
</Dynamic>
</Show>
</Dynamic>
</Dynamic>
</Dynamic>
)}
</Show>
)
}

type Kind = "add" | "del" | "mix"

type Filter = {
Expand Down Expand Up @@ -74,13 +113,15 @@ export default function FileTree(props: {
draggable?: boolean
tooltip?: boolean
onFileClick?: (file: FileNode) => void
onFileMention?: (file: FileNode) => void

_filter?: Filter
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
}) {
const file = useFile()
const language = useLanguage()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true
Expand Down Expand Up @@ -415,15 +456,20 @@ export default function FileTree(props: {
open={expanded()}
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
>
<Collapsible.Trigger>
<Wrapper>
<Node node={node}>
<div class="size-4 flex items-center justify-center text-icon-weak">
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
</div>
</Node>
</Wrapper>
</Collapsible.Trigger>
<FileContextMenu
onMention={props.onFileMention ? () => props.onFileMention!(node) : undefined}
mentionLabel={language.t("session.files.mention")}
>
<Collapsible.Trigger>
<Wrapper>
<Node node={node}>
<div class="size-4 flex items-center justify-center text-icon-weak">
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
</div>
</Node>
</Wrapper>
</Collapsible.Trigger>
</FileContextMenu>
<Collapsible.Content class="relative pt-0.5">
<div
classList={{
Expand All @@ -443,6 +489,7 @@ export default function FileTree(props: {
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
onFileMention={props.onFileMention}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
Expand All @@ -452,12 +499,19 @@ export default function FileTree(props: {
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<Wrapper>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" />
</Node>
</Wrapper>
<FileContextMenu
onOpen={props.onFileClick ? () => props.onFileClick!(node) : undefined}
onMention={props.onFileMention ? () => props.onFileMention!(node) : undefined}
openLabel={language.t("common.open")}
mentionLabel={language.t("session.files.mention")}
>
<Wrapper>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" />
</Node>
</Wrapper>
</FileContextMenu>
</Match>
</Switch>
)
Expand Down
75 changes: 52 additions & 23 deletions packages/app/src/components/session/session-sortable-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
Expand All @@ -25,7 +26,13 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
)
}

export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
export function SortableTab(props: {
tab: string
onTabClose: (tab: string) => void
onClick?: () => void
onCloseOthers?: (tab: string) => void
onMention?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
Expand All @@ -35,28 +42,50 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>
<ContextMenu>
<ContextMenu.Trigger
as={Tabs.Trigger}
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onClick={props.onClick}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.onTabClose(props.tab)}>
<ContextMenu.ItemLabel>{language.t("common.closeTab")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={props.onCloseOthers}>
<ContextMenu.Item onSelect={() => props.onCloseOthers?.(props.tab)}>
<ContextMenu.ItemLabel>{language.t("session.tab.closeOthers")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<Show when={props.onMention}>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => props.onMention?.(props.tab)}>
<ContextMenu.ItemLabel>{language.t("session.files.mention")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
</div>
)
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export const dict = {
"session.tab.session": "جلسة",
"session.tab.review": "مراجعة",
"session.tab.context": "سياق",
"session.tab.closeOthers": "إغلاق البقية",
"session.panel.reviewAndFiles": "المراجعة والملفات",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
"session.review.change.one": "تغيير",
Expand All @@ -430,6 +431,7 @@ export const dict = {
"session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات",
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.files.mention": "إشارة",
"session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
Expand Down Expand Up @@ -487,6 +489,7 @@ export const dict = {
"common.archive": "أرشفة",
"common.delete": "حذف",
"common.close": "إغلاق",
"common.open": "فتح",
"common.edit": "تحرير",
"common.loadMore": "تحميل المزيد",
"common.key.esc": "ESC",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ export const dict = {
"session.tab.session": "Sessão",
"session.tab.review": "Revisão",
"session.tab.context": "Contexto",
"session.tab.closeOthers": "Fechar outras",
"session.panel.reviewAndFiles": "Revisão e arquivos",
"session.review.filesChanged": "{{count}} Arquivos Alterados",
"session.review.change.one": "Alteração",
Expand All @@ -431,6 +432,7 @@ export const dict = {
"session.files.selectToOpen": "Selecione um arquivo para abrir",
"session.files.all": "Todos os arquivos",
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
"session.files.mention": "Mencionar",
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
"session.messages.loadEarlier": "Carregar mensagens anteriores",
Expand Down Expand Up @@ -491,6 +493,7 @@ export const dict = {
"common.archive": "Arquivar",
"common.delete": "Excluir",
"common.close": "Fechar",
"common.open": "Abrir",
"common.edit": "Editar",
"common.loadMore": "Carregar mais",
"common.key.esc": "ESC",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/bs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ export const dict = {
"session.tab.session": "Sesija",
"session.tab.review": "Pregled",
"session.tab.context": "Kontekst",
"session.tab.closeOthers": "Zatvori ostale",
"session.panel.reviewAndFiles": "Pregled i datoteke",
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
"session.review.change.one": "Izmjena",
Expand All @@ -458,6 +459,7 @@ export const dict = {
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
"session.files.all": "Sve datoteke",
"session.files.binaryContent": "Binarna datoteka (sadržaj se ne može prikazati)",
"session.files.mention": "Spomeni",

"session.messages.renderEarlier": "Prikaži ranije poruke",
"session.messages.loadingEarlier": "Učitavanje ranijih poruka...",
Expand Down Expand Up @@ -517,6 +519,7 @@ export const dict = {
"common.archive": "Arhiviraj",
"common.delete": "Izbriši",
"common.close": "Zatvori",
"common.open": "Otvori",
"common.edit": "Uredi",
"common.loadMore": "Učitaj još",
"common.key.esc": "ESC",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export const dict = {
"session.tab.session": "Session",
"session.tab.review": "Gennemgang",
"session.tab.context": "Kontekst",
"session.tab.closeOthers": "Luk andre",
"session.panel.reviewAndFiles": "Gennemgang og filer",
"session.review.filesChanged": "{{count}} Filer ændret",
"session.review.change.one": "Ændring",
Expand All @@ -432,6 +433,7 @@ export const dict = {
"session.files.selectToOpen": "Vælg en fil at åbne",
"session.files.all": "Alle filer",
"session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
"session.files.mention": "Nævn",
"session.messages.renderEarlier": "Vis tidligere beskeder",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
"session.messages.loadEarlier": "Indlæs tidligere beskeder",
Expand Down Expand Up @@ -491,6 +493,7 @@ export const dict = {
"common.archive": "Arkivér",
"common.delete": "Slet",
"common.close": "Luk",
"common.open": "Åbn",
"common.edit": "Rediger",
"common.loadMore": "Indlæs flere",

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export const dict = {
"session.tab.session": "Sitzung",
"session.tab.review": "Überprüfung",
"session.tab.context": "Kontext",
"session.tab.closeOthers": "Andere schließen",
"session.panel.reviewAndFiles": "Überprüfung und Dateien",
"session.review.filesChanged": "{{count}} Dateien geändert",
"session.review.change.one": "Änderung",
Expand All @@ -474,6 +475,7 @@ export const dict = {
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
"session.files.all": "Alle Dateien",
"session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
"session.files.mention": "Erwähnen",
"session.messages.renderEarlier": "Frühere Nachrichten rendern",
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
"session.messages.loadEarlier": "Frühere Nachrichten laden",
Expand Down Expand Up @@ -534,6 +536,7 @@ export const dict = {
"common.archive": "Archivieren",
"common.delete": "Löschen",
"common.close": "Schließen",
"common.open": "Öffnen",
"common.edit": "Bearbeiten",
"common.loadMore": "Mehr laden",

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ export const dict = {
"session.tab.session": "Session",
"session.tab.review": "Review",
"session.tab.context": "Context",
"session.tab.closeOthers": "Close others",
"session.panel.reviewAndFiles": "Review and files",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.one": "Change",
Expand All @@ -495,6 +496,7 @@ export const dict = {
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
"session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.files.mention": "Mention",

"session.messages.renderEarlier": "Render earlier messages",
"session.messages.loadingEarlier": "Loading earlier messages...",
Expand Down Expand Up @@ -561,6 +563,7 @@ export const dict = {
"common.archive": "Archive",
"common.delete": "Delete",
"common.close": "Close",
"common.open": "Open",
"common.edit": "Edit",
"common.loadMore": "Load more",
"common.key.esc": "ESC",
Expand Down
Loading
Loading