Add workbench tabs (#6788)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2024-10-03 01:49:49 +07:00
committed by GitHub
parent ce2621d472
commit b5fed4879f
36 changed files with 794 additions and 69 deletions
@@ -16,11 +16,20 @@
import { Analytics } from '@hcengineering/analytics'
import contact, { PersonAccount } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources'
import core, { AccountRole, Class, Doc, Ref, Space, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import core, {
AccountRole,
Class,
Doc,
getCurrentAccount,
hasAccountRole,
Ref,
SortingOrder,
Space
} from '@hcengineering/core'
import login from '@hcengineering/login'
import notification, { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification'
import { BrowserNotificatator, InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { IntlString, broadcastEvent, getMetadata, getResource } from '@hcengineering/platform'
import { broadcastEvent, getMetadata, getResource, IntlString } from '@hcengineering/platform'
import {
ActionContext,
ComponentExtensions,
@@ -30,55 +39,62 @@
reduceCalls
} from '@hcengineering/presentation'
import setting from '@hcengineering/setting'
import support, { SupportStatus, supportLink } from '@hcengineering/support'
import support, { supportLink, SupportStatus } from '@hcengineering/support'
import {
AnyComponent,
areLocationsEqual,
Button,
closePanel,
closePopup,
closeTooltip,
CompAndProps,
Component,
defineSeparators,
deviceOptionsStore as deviceInfo,
Dock,
getCurrentLocation,
getLocation,
IconSettings,
Label,
Location,
location,
locationStorageKeyId,
locationToUrl,
mainSeparators,
navigate,
openPanel,
PanelInstance,
Popup,
PopupAlignment,
PopupPosAlignment,
PopupResult,
ResolvedLocation,
Separator,
TooltipInstance,
areLocationsEqual,
closePanel,
closePopup,
closeTooltip,
defineSeparators,
deviceOptionsStore as deviceInfo,
getCurrentLocation,
getLocation,
location,
locationStorageKeyId,
navigate,
openPanel,
popupstore,
pushRootBarComponent,
ResolvedLocation,
resolvedLocationStore,
Separator,
setResolvedLocation,
showPopup,
workbenchSeparators,
mainSeparators
TooltipInstance,
workbenchSeparators
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import {
accessDeniedStore,
ActionHandler,
ListSelectionProvider,
NavLink,
accessDeniedStore,
migrateViewOpttions,
NavLink,
parseLinkId,
updateFocus
} from '@hcengineering/view-resources'
import type { Application, NavigatorModel, SpecialNavModel, ViewConfiguration } from '@hcengineering/workbench'
import type {
Application,
NavigatorModel,
SpecialNavModel,
ViewConfiguration,
WorkbenchTab
} from '@hcengineering/workbench'
import { getContext, onDestroy, onMount, tick } from 'svelte'
import { subscribeMobile } from '../mobile'
import workbench from '../plugin'
@@ -96,6 +112,7 @@
import TopMenu from './icons/TopMenu.svelte'
import WidgetsBar from './sidebar/Sidebar.svelte'
import { sidebarStore, SidebarVariant, syncSidebarState } from '../sidebar'
import { getTabLocation, selectTab, syncWorkbenchTab, tabIdStore, tabsStore } from '../workbench'
let contentPanel: HTMLElement
@@ -142,13 +159,74 @@
}
}
let tabs: WorkbenchTab[] = []
let areTabsLoaded = false
let prevTab: Ref<WorkbenchTab> | undefined
const query = createQuery()
$: query.query(
workbench.class.WorkbenchTab,
{},
(res) => {
tabs = res
tabsStore.set(tabs)
if (!areTabsLoaded) {
void initCurrentTab(tabs)
areTabsLoaded = true
}
},
{
sort: {
isPinned: SortingOrder.Descending,
createdOn: SortingOrder.Ascending
}
}
)
async function initCurrentTab (tabs: WorkbenchTab[]): Promise<void> {
if (tabs.length === 0) {
const loc = getCurrentLocation()
const _id = await client.createDoc(workbench.class.WorkbenchTab, core.space.Workspace, {
attachedTo: account._id,
location: locationToUrl(loc),
isPinned: false
})
prevTab = _id
selectTab(_id)
} else {
const tab = tabs.find((t) => t._id === $tabIdStore)
const loc = getCurrentLocation()
const tabLoc = tab ? getTabLocation(tab) : undefined
const isLocEqual = tabLoc ? areLocationsEqual(loc, tabLoc) : false
if (!isLocEqual) {
const url = locationToUrl(loc)
const tabByUrl = tabs.find((t) => t.location === url)
if (tabByUrl !== undefined) {
prevTab = tabByUrl._id
selectTab(tabByUrl._id)
} else {
const _id = await client.createDoc(workbench.class.WorkbenchTab, core.space.Workspace, {
attachedTo: account._id,
location: url,
isPinned: false
})
prevTab = _id
selectTab(_id)
}
}
}
}
onMount(() => {
pushRootBarComponent('right', view.component.SearchSelector)
pushRootBarComponent('left', workbench.component.WorkbenchTabs, 30)
void getResource(login.function.GetWorkspaces).then(async (getWorkspaceFn) => {
$workspacesStore = await getWorkspaceFn()
await updateWindowTitle(getLocation())
})
syncSidebarState()
syncWorkbenchTab()
})
const account = getCurrentAccount() as PersonAccount
@@ -296,7 +374,13 @@
async function syncLoc (loc: Location): Promise<void> {
accessDeniedStore.set(false)
const originalLoc = JSON.stringify(loc)
if ($tabIdStore !== prevTab) {
if (prevTab) {
clear(1)
clear(2)
}
prevTab = $tabIdStore
}
if (loc.path.length > 3 && getSpecialComponent(loc.path[3]) === undefined) {
// resolve short links
const resolvedLoc = await resolveShortLink(loc)
@@ -0,0 +1,135 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { AnySvelteComponent, closePanel, getCurrentLocation, Location, ModernTab, navigate } from '@hcengineering/ui'
import { ComponentExtensions, getClient } from '@hcengineering/presentation'
import { Asset, getResource, IntlString } from '@hcengineering/platform'
import { type Application, WorkbenchTab } from '@hcengineering/workbench'
import { Class, Doc, Ref } from '@hcengineering/core'
import view from '@hcengineering/view'
import { parseLinkId, showMenu } from '@hcengineering/view-resources'
import { Analytics } from '@hcengineering/analytics'
import { closeTab, getTabLocation, selectTab, tabIdStore, tabsStore } from '../workbench'
import workbench from '../plugin'
export let tab: WorkbenchTab
const client = getClient()
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
let name: string | undefined = undefined
let label: IntlString | undefined = undefined
let icon: Asset | AnySvelteComponent | undefined
let iconProps: Record<string, any> | undefined
async function getResolvedLocation (loc: Location, app?: Application): Promise<Location> {
if (app === undefined) return loc
if (app.locationResolver) {
const resolver = await getResource(app.locationResolver)
return (await resolver(loc))?.loc ?? loc
}
return loc
}
async function getTabName (loc: Location): Promise<string | undefined> {
if (loc.fragment == null) return
const hierarchy = client.getHierarchy()
const [, id, _class] = decodeURIComponent(loc.fragment).split('|')
if (_class == null) return
const mixin = hierarchy.classHierarchyMixin(_class as Ref<Class<Doc>>, view.mixin.ObjectTitle)
if (mixin === undefined) return
const titleProvider = await getResource(mixin.titleProvider)
try {
const _id = await parseLinkId(linkProviders, id, _class as Ref<Class<Doc>>)
return await titleProvider(client, _id)
} catch (err: any) {
Analytics.handleError(err)
console.error(err)
}
}
async function updateTabData (tab: WorkbenchTab): Promise<void> {
const tabLoc = getTabLocation(tab)
const alias = tabLoc.path[2]
const application = client.getModel().findAllSync<Application>(workbench.class.Application, { alias })[0]
if (application?.locationDataResolver) {
const resolver = await getResource(application.locationDataResolver)
const data = await resolver(tabLoc)
name = data.name
label = data.nameIntl ?? application.label ?? workbench.string.Tab
if (data.iconComponent) {
icon = await getResource(data.iconComponent)
} else {
icon = data.icon ?? application?.icon
}
iconProps = data.iconProps
} else {
const special = tabLoc.path[3]
const specialLabel = application?.navigatorModel?.specials?.find((s) => s.id === special)?.label
const resolvedLoc = await getResolvedLocation(tabLoc, application)
name = await getTabName(resolvedLoc)
label = specialLabel ?? application?.label ?? workbench.string.Tab
icon = application?.icon
iconProps = undefined
}
}
$: void updateTabData(tab)
function handleClickTab (): void {
selectTab(tab._id)
const tabLoc = getTabLocation(tab)
const loc = getCurrentLocation()
const currentApp = loc.path[2]
const newApp = tabLoc.path[2]
if (newApp && newApp !== currentApp) {
closePanel(false)
}
navigate(tabLoc)
}
function handleCloseTab (): void {
void closeTab(tab)
}
function handleMenu (event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
showMenu(event, { object: tab, baseMenuClass: workbench.class.WorkbenchTab })
}
</script>
<ModernTab
labelIntl={label}
label={name}
{icon}
{iconProps}
maxSize="12rem"
highlighted={$tabIdStore === tab._id}
canClose={$tabsStore.length > 1 && !tab.isPinned}
on:click={handleClickTab}
on:close={handleCloseTab}
on:contextmenu={handleMenu}
>
<svelte:fragment slot="prefix">
<ComponentExtensions extension={workbench.extensions.WorkbenchTabExtensions} props={{ tab }} />
</svelte:fragment>
</ModernTab>
@@ -0,0 +1,38 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createTab, tabsStore } from '../workbench'
import WorkbenchTabPresenter from './WorkbenchTabPresenter.svelte'
import { IconAdd, ButtonIcon } from '@hcengineering/ui'
</script>
<div class="root flex-gap-1">
{#each $tabsStore as tab}
<WorkbenchTabPresenter {tab} />
{/each}
<div class="ml-1-5 plus-button mr-1">
<ButtonIcon icon={IconAdd} size="min" kind="tertiary" on:click={createTab} />
</div>
</div>
<style>
.root {
display: flex;
align-items: center;
}
.plus-button {
height: 0.875rem;
}
</style>
+10 -3
View File
@@ -23,7 +23,9 @@ import WorkbenchApp from './components/WorkbenchApp.svelte'
import { doNavigate } from './utils'
import Workbench from './components/Workbench.svelte'
import ServerManager from './components/ServerManager.svelte'
import WorkbenchTabs from './components/WorkbenchTabs.svelte'
import { isAdminUser } from '@hcengineering/presentation'
import { canCloseTab, closeTab, pinTab, unpinTab } from './workbench'
async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> {
return spaces.find((sp) => sp.archived) !== undefined
@@ -46,13 +48,18 @@ export default async (): Promise<Resources> => ({
SpacePanel,
SpecialView,
Workbench,
ServerManager
ServerManager,
WorkbenchTabs
},
function: {
HasArchiveSpaces: hasArchiveSpaces,
IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser()
IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser(),
CanCloseTab: canCloseTab
},
actionImpl: {
Navigate: doNavigate
Navigate: doNavigate,
PinTab: pinTab,
UnpinTab: unpinTab,
CloseTab: closeTab
}
})
+4 -2
View File
@@ -47,13 +47,15 @@ export default mergeIds(workbenchId, workbench, {
WorkspaceCreating: '' as IntlString,
AccessDenied: '' as IntlString,
Widget: '' as IntlString,
WidgetPreference: '' as IntlString
WidgetPreference: '' as IntlString,
Tab: '' as IntlString
},
metadata: {
MobileAllowed: '' as Metadata<boolean>
},
component: {
SpacePanel: '' as AnyComponent,
Workbench: '' as AnyComponent
Workbench: '' as AnyComponent,
WorkbenchTabs: '' as AnyComponent
}
})
@@ -0,0 +1,138 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { derived, get, writable } from 'svelte/store'
import core, { concatLink, getCurrentAccount, type Ref } from '@hcengineering/core'
import workbench, { type WorkbenchTab } from '@hcengineering/workbench'
import {
location as locationStore,
locationToUrl,
parseLocation,
type Location,
navigate,
getCurrentLocation
} from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import { workspaceStore } from './utils'
export const tabIdStore = writable<Ref<WorkbenchTab> | undefined>()
export const tabsStore = writable<WorkbenchTab[]>([])
export const currentTabStore = derived([tabIdStore, tabsStore], ([tabId, tabs]) => {
return tabs.find((tab) => tab._id === tabId)
})
workspaceStore.subscribe((workspace) => {
tabIdStore.set(getTabFromLocalStorage(workspace ?? ''))
})
locationStore.subscribe((loc) => {
const workspace = get(workspaceStore)
if (workspace == null || workspace === '') return
const tab = get(currentTabStore)
if (tab == null) return
const tabId = tab._id
if (tabId == null || tab._id !== tabId) return
void getClient().update(tab, { location: locationToUrl(loc) })
})
tabIdStore.subscribe(saveTabToLocalStorage)
export function syncWorkbenchTab (): void {
const workspace = get(workspaceStore)
tabIdStore.set(getTabFromLocalStorage(workspace ?? ''))
}
function getTabIdLocalStorageKey (workspace: string): string | undefined {
const me = getCurrentAccount()
if (me == null || workspace === '') return undefined
return `workbench.${workspace}.${me.person}.tab`
}
function getTabFromLocalStorage (workspace: string): Ref<WorkbenchTab> | undefined {
const localStorageKey = getTabIdLocalStorageKey(workspace)
if (localStorageKey === undefined) return undefined
const tab = window.localStorage.getItem(localStorageKey)
if (tab == null || tab === '') return undefined
return tab as Ref<WorkbenchTab>
}
function saveTabToLocalStorage (_id: Ref<WorkbenchTab> | undefined): void {
const workspace = get(workspaceStore)
if (workspace == null || workspace === '') return
const localStorageKey = getTabIdLocalStorageKey(workspace)
if (localStorageKey === undefined) return
window.localStorage.setItem(localStorageKey, _id ?? '')
}
export function selectTab (_id: Ref<WorkbenchTab>): void {
tabIdStore.set(_id)
}
export function getTabLocation (tab: WorkbenchTab): Location {
const base = `${window.location.protocol}//${window.location.host}`
const url = new URL(concatLink(base, tab.location))
return parseLocation(url)
}
export async function closeTab (tab: WorkbenchTab): Promise<void> {
const tabs = get(tabsStore)
const index = tabs.findIndex((t) => t._id === tab._id)
if (index === -1) return
tabsStore.update((tabs) => tabs.filter((t) => t._id !== tab._id))
if (get(tabIdStore) === tab._id) {
const newTab = tabs[index - 1] ?? tabs[index + 1]
tabIdStore.set(newTab?._id)
if (newTab !== undefined) {
navigate(getTabLocation(newTab))
}
}
const client = getClient()
await client.remove(tab)
}
export async function createTab (): Promise<void> {
const loc = getCurrentLocation()
const client = getClient()
const me = getCurrentAccount()
const tab = await client.createDoc(workbench.class.WorkbenchTab, core.space.Workspace, {
attachedTo: me._id,
location: locationToUrl(loc),
isPinned: false
})
selectTab(tab)
}
export function canCloseTab (tab: WorkbenchTab): boolean {
const tabs = get(tabsStore)
return tabs.length > 1 && tabs.some((t) => t._id === tab._id)
}
export async function pinTab (tab: WorkbenchTab): Promise<void> {
const client = getClient()
await client.update(tab, { isPinned: true })
}
export async function unpinTab (tab: WorkbenchTab): Promise<void> {
const client = getClient()
await client.update(tab, { isPinned: false })
}