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
4 changes: 2 additions & 2 deletions src/components/databrowser/components/databrowser-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,9 @@ function AddTabButton() {
variant="secondary"
size="icon-sm"
onClick={handleAddTab}
className="flex-shrink-0"
className="flex-shrink-0 dark:bg-zinc-200"
>
<IconPlus className="text-zinc-500" size={16} />
<IconPlus className="text-zinc-500 dark:text-zinc-600" size={16} />
</Button>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,30 @@ export function DeleteAlertDialog({
open,
onOpenChange,
deletionType,
count = 1,
}: {
children?: React.ReactNode
onDeleteConfirm: MouseEventHandler
open?: boolean
onOpenChange?: (open: boolean) => void
deletionType: "item" | "key"
count?: number
}) {
const isPlural = count > 1
const itemLabel = deletionType === "item" ? "Item" : "Key"
const itemsLabel = deletionType === "item" ? "Items" : "Keys"

return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
{children && <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>}

<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{deletionType === "item" ? "Delete Item" : "Delete Key"}
{isPlural ? `Delete ${count} ${itemsLabel}` : `Delete ${itemLabel}`}
</AlertDialogTitle>
<AlertDialogDescription className="mt-5">
Are you sure you want to delete this {deletionType}?<br />
Are you sure you want to delete {isPlural ? `these ${count} ${deletionType}s` : `this ${deletionType}`}?<br />
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
Expand Down
31 changes: 23 additions & 8 deletions src/components/databrowser/components/sidebar-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,23 @@ import { DeleteAlertDialog } from "./display/delete-alert-dialog"
export const SidebarContextMenu = ({ children }: PropsWithChildren) => {
const { mutate: deleteKey } = useDeleteKey()
const [isAlertOpen, setAlertOpen] = useState(false)
const [dataKey, setDataKey] = useState("")
const { addTab, setSelectedKey, selectTab, setSearch } = useDatabrowserStore()
const { search: currentSearch } = useTab()
const [contextKeys, setContextKeys] = useState<string[]>([])
const { addTab, setSelectedKey: setSelectedKeyGlobal, selectTab, setSearch } = useDatabrowserStore()
const { search: currentSearch, selectedKeys, setSelectedKey } = useTab()

return (
<>
<DeleteAlertDialog
deletionType="key"
count={contextKeys.length}
open={isAlertOpen}
onOpenChange={setAlertOpen}
onDeleteConfirm={(e) => {
e.stopPropagation()
deleteKey(dataKey)
// Delete all selected keys
for (const key of contextKeys) {
deleteKey(key)
}
setAlertOpen(false)
}}
/>
Expand All @@ -42,7 +46,16 @@ export const SidebarContextMenu = ({ children }: PropsWithChildren) => {
const key = el.closest("[data-key]")

if (key && key instanceof HTMLElement && key.dataset.key !== undefined) {
setDataKey(key.dataset.key)
const clickedKey = key.dataset.key

// If right-clicking on a selected key, keep all selected keys
if (selectedKeys.includes(clickedKey)) {
setContextKeys(selectedKeys)
} else {
// If right-clicking on an unselected key, select only that key
setSelectedKey(clickedKey)
setContextKeys([clickedKey])
}
} else {
throw new Error("Key not found")
}
Expand All @@ -53,32 +66,34 @@ export const SidebarContextMenu = ({ children }: PropsWithChildren) => {
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
navigator.clipboard.writeText(dataKey)
navigator.clipboard.writeText(contextKeys[0])
toast({
description: "Key copied to clipboard",
})
}}
className="gap-2"
disabled={contextKeys.length !== 1}
>
<IconCopy size={16} />
Copy key
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
const newTabId = addTab()
setSelectedKey(newTabId, dataKey)
setSelectedKeyGlobal(newTabId, contextKeys[0])
setSearch(newTabId, currentSearch)
selectTab(newTabId)
}}
className="gap-2"
disabled={contextKeys.length !== 1}
>
<IconExternalLink size={16} />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => setAlertOpen(true)} className="gap-2">
<IconTrash size={16} />
Delete key
{contextKeys.length > 1 ? `Delete ${contextKeys.length} keys` : "Delete key"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
Expand Down
2 changes: 1 addition & 1 deletion src/components/databrowser/components/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function Sidebar() {
<div className="flex gap-1">
<Button
aria-label="Refresh"
className="h-7 w-7 px-0 text-zinc-500"
className="h-7 w-7 px-0 text-zinc-500 dark:text-zinc-600"
onClick={() => {
queryClient.invalidateQueries({
queryKey: [FETCH_KEYS_QUERY_KEY],
Expand Down
64 changes: 53 additions & 11 deletions src/components/databrowser/components/sidebar/keys-list.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useRef } from "react"
import { useTab } from "@/tab-provider"
import type { DataType, RedisKey } from "@/types"

Expand All @@ -10,12 +11,26 @@ import { SidebarContextMenu } from "../sidebar-context-menu"

export const KeysList = () => {
const { keys } = useKeys()
const lastClickedIndexRef = useRef<number | null>(null)

return (
<SidebarContextMenu>
<>
{/* Since the selection border is overflowing, we need a px padding for the first item */}
<div className="h-px" />
{keys.map((data, i) => (
<KeyItem key={data[0]} nextKey={keys.at(i + 1)?.[0] ?? ""} data={data} />
<>
<KeyItem
key={data[0]}
index={i}
data={data}
allKeys={keys}
lastClickedIndexRef={lastClickedIndexRef}
/>
{i !== keys.length - 1 && (
<div className="-z-10 mx-2 h-px bg-zinc-100 dark:bg-zinc-200" />
)}
</>
))}
</>
</SidebarContextMenu>
Expand All @@ -32,31 +47,58 @@ const keyStyles = {
stream: "border-green-400 !bg-green-50 text-green-900",
} as Record<DataType, string>

const KeyItem = ({ data, nextKey }: { data: RedisKey; nextKey: string }) => {
const { selectedKey, setSelectedKey } = useTab()
const KeyItem = ({
data,
index,
allKeys,
lastClickedIndexRef,
}: {
data: RedisKey
index: number
allKeys: RedisKey[]
lastClickedIndexRef: React.MutableRefObject<number | null>
}) => {
const { selectedKeys, setSelectedKeys, setSelectedKey } = useTab()

const [dataKey, dataType] = data
const isKeySelected = selectedKey === dataKey
const isNextKeySelected = selectedKey === nextKey
const isKeySelected = selectedKeys.includes(dataKey)

const handleClick = (e: React.MouseEvent) => {
if (e.shiftKey && lastClickedIndexRef.current !== null) {
// Shift+Click: select range
const start = Math.min(lastClickedIndexRef.current, index)
const end = Math.max(lastClickedIndexRef.current, index)
const rangeKeys = allKeys.slice(start, end + 1).map(([key]) => key)
setSelectedKeys(rangeKeys)
} else if (e.metaKey || e.ctrlKey) {
// cmd/ctrl+click to toggle selection
if (isKeySelected) {
setSelectedKeys(selectedKeys.filter((k) => k !== dataKey))
} else {
setSelectedKeys([...selectedKeys, dataKey])
}
lastClickedIndexRef.current = index
} else {
// Regular click: select single key
setSelectedKey(dataKey)
lastClickedIndexRef.current = index
}
}

return (
<Button
data-key={dataKey}
variant={isKeySelected ? "default" : "ghost"}
className={cn(
"relative flex h-10 w-full items-center justify-start gap-2 px-3 py-0 !ring-0 focus-visible:bg-zinc-50",
"select-none border border-transparent text-left",
"-my-px select-none border border-transparent text-left",
isKeySelected && "shadow-sm",
isKeySelected && keyStyles[dataType]
)}
onClick={() => setSelectedKey(dataKey)}
onClick={handleClick}
>
<TypeTag variant={dataType} type="icon" />
<p className="truncate whitespace-nowrap">{dataKey}</p>

{!isKeySelected && !isNextKeySelected && (
<span className="absolute -bottom-px left-3 right-3 h-px bg-zinc-100" />
)}
</Button>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ export const SearchInput = () => {
<div className="relative grow">
<Popover open={isFocus && filteredHistory.length > 0}>
<PopoverTrigger asChild>
<div>
<div className="h-8 rounded-md rounded-l-none border border-zinc-300 font-normal">
<Input
ref={inputRef}
placeholder="Search"
className={"rounded-l-none border-zinc-300 font-normal"}
className={"h-full rounded-l-none border-none pr-6"}
onKeyDown={handleKeyDown}
onChange={(e) => {
setState(e.currentTarget.value)
Expand Down
2 changes: 1 addition & 1 deletion src/components/databrowser/components/tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const Tab = ({ id, isList }: { id: TabId; isList?: boolean }) => {
e.stopPropagation()
removeTab(id)
}}
className="p-1 text-zinc-300 transition-colors hover:text-zinc-500"
className="p-1 text-zinc-300 transition-colors hover:text-zinc-500 dark:text-zinc-400"
>
<IconX size={16} />
</button>
Expand Down
36 changes: 29 additions & 7 deletions src/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const DatabrowserProvider = ({
setItem: (_name, value) => storage.set(JSON.stringify(value)),
removeItem: () => {},
},
version: 2,
version: 3,
// @ts-expect-error Reset the store for < v1
migrate: (originalState, version) => {
const state = originalState as DatabrowserStore
Expand All @@ -60,6 +60,23 @@ export const DatabrowserProvider = ({
}
}

if (version === 2) {
// Migrate from selectedKey to selectedKeys
return {
...state,
tabs: state.tabs.map(([id, data]) => {
const oldData = data as any
return [
id,
{
...data,
selectedKeys: oldData.selectedKey ? [oldData.selectedKey] : [],
},
]
}),
}
}

return state
},
})
Expand Down Expand Up @@ -102,7 +119,7 @@ export type SelectedItem = {

export type TabData = {
id: TabId
selectedKey: string | undefined
selectedKeys: string[]
selectedListItem?: SelectedItem

search: SearchFilter
Expand All @@ -128,8 +145,9 @@ type DatabrowserStore = {
closeAllButPinned: () => void

// Tab actions
getSelectedKey: (tabId: TabId) => string | undefined
getSelectedKeys: (tabId: TabId) => string[]
setSelectedKey: (tabId: TabId, key: string | undefined) => void
setSelectedKeys: (tabId: TabId, keys: string[]) => void
setSelectedListItem: (tabId: TabId, item?: { key: string; isNew?: boolean }) => void
setSearch: (tabId: TabId, search: SearchFilter) => void
setSearchKey: (tabId: TabId, key: string) => void
Expand All @@ -150,7 +168,7 @@ const storeCreator: StateCreator<DatabrowserStore> = (set, get) => ({

const newTabData: TabData = {
id,
selectedKey: undefined,
selectedKeys: [],
search: { key: "", type: undefined },
pinned: false,
}
Expand Down Expand Up @@ -275,18 +293,22 @@ const storeCreator: StateCreator<DatabrowserStore> = (set, get) => ({
set({ selectedTab: id })
},

getSelectedKey: (tabId) => {
return get().tabs.find(([id]) => id === tabId)?.[1]?.selectedKey
getSelectedKeys: (tabId) => {
return get().tabs.find(([id]) => id === tabId)?.[1]?.selectedKeys ?? []
},

setSelectedKey: (tabId, key) => {
get().setSelectedKeys(tabId, key ? [key] : [])
},

setSelectedKeys: (tabId, keys) => {
set((old) => {
const tabIndex = old.tabs.findIndex(([id]) => id === tabId)
if (tabIndex === -1) return old

const newTabs = [...old.tabs]
const [, tabData] = newTabs[tabIndex]
newTabs[tabIndex] = [tabId, { ...tabData, selectedKey: key, selectedListItem: undefined }]
newTabs[tabIndex] = [tabId, { ...tabData, selectedKeys: keys, selectedListItem: undefined }]

return { ...old, tabs: newTabs }
})
Expand Down
5 changes: 4 additions & 1 deletion src/tab-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const useTab = () => {
selectedTab,
tabs,
setSelectedKey,
setSelectedKeys,
setSelectedListItem,
setSearch,
setSearchKey,
Expand All @@ -37,12 +38,14 @@ export const useTab = () => {
return useMemo(
() => ({
active: selectedTab === tabId,
selectedKey: tabData.selectedKey,
selectedKey: tabData.selectedKeys[0], // Backwards compatibility - first selected key
selectedKeys: tabData.selectedKeys,
selectedListItem: tabData.selectedListItem,
search: tabData.search,
pinned: tabData.pinned,

setSelectedKey: (key: string | undefined) => setSelectedKey(tabId, key),
setSelectedKeys: (keys: string[]) => setSelectedKeys(tabId, keys),
setSelectedListItem: (item: SelectedItem | undefined) => setSelectedListItem(tabId, item),
setSearch: (search: SearchFilter) => setSearch(tabId, search),
setSearchKey: (key: string) => setSearchKey(tabId, key),
Expand Down