Add workbench tabs (#6788)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-10-02 22:49:49 +04:00 committed by GitHub
parent ce2621d472
commit b5fed4879f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 794 additions and 69 deletions

View File

@ -64,6 +64,7 @@ export function createModel (builder: Builder): void {
core.space.Model, core.space.Model,
{ {
label: chunter.string.ApplicationLabelChunter, label: chunter.string.ApplicationLabelChunter,
locationDataResolver: chunter.function.LocationDataResolver,
icon: chunter.icon.Chunter, icon: chunter.icon.Chunter,
alias: chunterId, alias: chunterId,
hidden: false, hidden: false,
@ -87,6 +88,11 @@ export function createModel (builder: Builder): void {
chunter.ids.ChatWidget chunter.ids.ChatWidget
) )
builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, {
extension: workbench.extensions.WorkbenchTabExtensions,
component: chunter.component.WorkbenchTabExtension
})
const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage] const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage]
spaceClasses.forEach((spaceClass) => { spaceClasses.forEach((spaceClass) => {

View File

@ -22,7 +22,7 @@ import type { IntlString, Resource } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform' import { mergeIds } from '@hcengineering/platform'
import type { AnyComponent, Location } from '@hcengineering/ui/src/types' import type { AnyComponent, Location } from '@hcengineering/ui/src/types'
import type { Action, ActionCategory, ViewAction, Viewlet, ViewletDescriptor } from '@hcengineering/view' import type { Action, ActionCategory, ViewAction, Viewlet, ViewletDescriptor } from '@hcengineering/view'
import { type WidgetTab } from '@hcengineering/workbench' import { type WidgetTab, type LocationData } from '@hcengineering/workbench'
export default mergeIds(chunterId, chunter, { export default mergeIds(chunterId, chunter, {
component: { component: {
@ -34,7 +34,8 @@ export default mergeIds(chunterId, chunter, {
ChatWidgetTab: '' as AnyComponent, ChatWidgetTab: '' as AnyComponent,
ChatMessageNotificationLabel: '' as AnyComponent, ChatMessageNotificationLabel: '' as AnyComponent,
ThreadNotificationPresenter: '' as AnyComponent, ThreadNotificationPresenter: '' as AnyComponent,
JoinChannelNotificationPresenter: '' as AnyComponent JoinChannelNotificationPresenter: '' as AnyComponent,
WorkbenchTabExtension: '' as AnyComponent
}, },
action: { action: {
MarkCommentUnread: '' as Ref<Action>, MarkCommentUnread: '' as Ref<Action>,
@ -108,7 +109,8 @@ export default mergeIds(chunterId, chunter, {
ReplyToThread: '' as Resource<(doc: ActivityMessage, event: MouseEvent) => Promise<void>>, ReplyToThread: '' as Resource<(doc: ActivityMessage, event: MouseEvent) => Promise<void>>,
CanReplyToThread: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanReplyToThread: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
GetMessageLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>, GetMessageLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
CloseChatWidgetTab: '' as Resource<(tab: WidgetTab) => Promise<void>> CloseChatWidgetTab: '' as Resource<(tab: WidgetTab) => Promise<void>>,
LocationDataResolver: '' as Resource<(loc: Location) => Promise<LocationData>>
}, },
filter: { filter: {
ChatMessagesFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean> ChatMessagesFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>

View File

@ -38,6 +38,7 @@
"@hcengineering/model-attachment": "^0.6.0", "@hcengineering/model-attachment": "^0.6.0",
"@hcengineering/model-chunter": "^0.6.0", "@hcengineering/model-chunter": "^0.6.0",
"@hcengineering/model-core": "^0.6.0", "@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-guest": "^0.6.0",
"@hcengineering/model-notification": "^0.6.0", "@hcengineering/model-notification": "^0.6.0",
"@hcengineering/model-presentation": "^0.6.0", "@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/model-view": "^0.6.0", "@hcengineering/model-view": "^0.6.0",
@ -48,7 +49,7 @@
"@hcengineering/templates": "^0.6.11", "@hcengineering/templates": "^0.6.11",
"@hcengineering/ui": "^0.6.15", "@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13", "@hcengineering/view": "^0.6.13",
"@hcengineering/model-guest": "^0.6.0", "@hcengineering/workbench": "^0.6.16",
"cross-fetch": "^3.1.5" "cross-fetch": "^3.1.5"
} }
} }

View File

@ -324,6 +324,7 @@ export function createModel (builder: Builder): void {
hidden: false, hidden: false,
// component: contact.component.ContactsTabs, // component: contact.component.ContactsTabs,
locationResolver: contact.resolver.Location, locationResolver: contact.resolver.Location,
locationDataResolver: contact.resolver.LocationData,
navigatorModel: { navigatorModel: {
spaces: [], spaces: [],
specials: [ specials: [

View File

@ -21,9 +21,10 @@ import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineer
import { type NotificationGroup } from '@hcengineering/notification' import { type NotificationGroup } from '@hcengineering/notification'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform' import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type TemplateFieldFunc } from '@hcengineering/templates' import { type TemplateFieldFunc } from '@hcengineering/templates'
import type { AnyComponent } from '@hcengineering/ui/src/types' import { type AnyComponent, type Location } from '@hcengineering/ui/src/types'
import { type Action, type ActionCategory, type ViewAction } from '@hcengineering/view' import { type Action, type ActionCategory, type ViewAction } from '@hcengineering/view'
import { type ChatMessageViewlet } from '@hcengineering/chunter' import { type ChatMessageViewlet } from '@hcengineering/chunter'
import { type LocationData } from '@hcengineering/workbench'
export default mergeIds(contactId, contact, { export default mergeIds(contactId, contact, {
activity: { activity: {
@ -63,7 +64,6 @@ export default mergeIds(contactId, contact, {
ChannelIcon: '' as AnyComponent ChannelIcon: '' as AnyComponent
}, },
string: { string: {
Persons: '' as IntlString,
SearchEmployee: '' as IntlString, SearchEmployee: '' as IntlString,
SearchPerson: '' as IntlString, SearchPerson: '' as IntlString,
SearchOrganization: '' as IntlString, SearchOrganization: '' as IntlString,
@ -97,7 +97,6 @@ export default mergeIds(contactId, contact, {
ConfigLabel: '' as IntlString, ConfigLabel: '' as IntlString,
ConfigDescription: '' as IntlString, ConfigDescription: '' as IntlString,
Employees: '' as IntlString,
People: '' as IntlString People: '' as IntlString
}, },
completion: { completion: {
@ -142,5 +141,8 @@ export default mergeIds(contactId, contact, {
ChannelIdentifierProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>, ChannelIdentifierProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
SetPersonStore: '' as Resource<(manager: DocManager<any>) => void>, SetPersonStore: '' as Resource<(manager: DocManager<any>) => void>,
PersonFilterFunction: '' as Resource<(doc: Doc, target: Doc) => boolean> PersonFilterFunction: '' as Resource<(doc: Doc, target: Doc) => boolean>
},
resolver: {
LocationData: '' as Resource<(loc: Location) => Promise<LocationData>>
} }
}) })

View File

@ -30,13 +30,14 @@
"dependencies": { "dependencies": {
"@hcengineering/core": "^0.6.32", "@hcengineering/core": "^0.6.32",
"@hcengineering/model": "^0.6.11", "@hcengineering/model": "^0.6.11",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/model-core": "^0.6.0", "@hcengineering/model-core": "^0.6.0",
"@hcengineering/workbench": "^0.6.16", "@hcengineering/model-preference": "^0.6.0",
"@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/ui": "^0.6.15", "@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13", "@hcengineering/view": "^0.6.13",
"@hcengineering/model-view": "^0.6.0", "@hcengineering/workbench": "^0.6.16",
"@hcengineering/workbench-resources": "^0.6.1", "@hcengineering/workbench-resources": "^0.6.1"
"@hcengineering/model-preference": "^0.6.0"
} }
} }

View File

@ -30,10 +30,12 @@ import type {
WidgetPreference, WidgetPreference,
WidgetTab, WidgetTab,
WidgetType, WidgetType,
SidebarEvent SidebarEvent,
WorkbenchTab
} from '@hcengineering/workbench' } from '@hcengineering/workbench'
import { type AnyComponent } from '@hcengineering/ui' import { type AnyComponent } from '@hcengineering/ui'
import core, { TClass, TDoc, TTx } from '@hcengineering/model-core' import core, { TClass, TDoc, TTx } from '@hcengineering/model-core'
import presentation from '@hcengineering/model-presentation'
import workbench from './plugin' import workbench from './plugin'
@ -98,6 +100,13 @@ export class TTxSidebarEvent extends TTx implements TxSidebarEvent {
params!: Record<string, any> params!: Record<string, any>
} }
@Model(workbench.class.WorkbenchTab, preference.class.Preference)
@UX(workbench.string.Tab)
export class TWorkbenchTab extends TPreference implements WorkbenchTab {
location!: string
isPinned!: boolean
}
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel( builder.createModel(
TApplication, TApplication,
@ -106,7 +115,8 @@ export function createModel (builder: Builder): void {
TApplicationNavModel, TApplicationNavModel,
TWidget, TWidget,
TWidgetPreference, TWidgetPreference,
TTxSidebarEvent TTxSidebarEvent,
TWorkbenchTab
) )
builder.mixin(workbench.class.Application, core.class.Class, view.mixin.ObjectPresenter, { builder.mixin(workbench.class.Application, core.class.Class, view.mixin.ObjectPresenter, {
@ -133,6 +143,52 @@ export function createModel (builder: Builder): void {
mode: ['workbench'] mode: ['workbench']
} }
}) })
createAction(builder, {
action: workbench.actionImpl.PinTab,
label: view.string.Pin,
icon: view.icon.Pin,
input: 'focus',
category: workbench.category.Workbench,
target: workbench.class.WorkbenchTab,
query: {
isPinned: false
},
context: {
mode: 'context',
group: 'edit'
}
})
createAction(builder, {
action: workbench.actionImpl.UnpinTab,
label: view.string.Unpin,
icon: view.icon.Pin,
input: 'focus',
category: workbench.category.Workbench,
target: workbench.class.WorkbenchTab,
query: {
isPinned: true
},
context: {
mode: 'context',
group: 'edit'
}
})
createAction(builder, {
action: workbench.actionImpl.CloseTab,
label: presentation.string.Close,
icon: view.icon.Delete,
input: 'focus',
category: workbench.category.Workbench,
target: workbench.class.WorkbenchTab,
visibilityTester: workbench.function.CanCloseTab,
context: {
mode: 'context',
group: 'edit'
}
})
} }
export default workbench export default workbench

View File

@ -13,11 +13,12 @@
// limitations under the License. // limitations under the License.
// //
import { type Doc, type Space } from '@hcengineering/core' import { type Doc, type Ref, type Space } from '@hcengineering/core'
import { type IntlString, type Resource, mergeIds } from '@hcengineering/platform' import { type IntlString, type Resource, mergeIds } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types' import { type AnyComponent } from '@hcengineering/ui/src/types'
import { workbenchId } from '@hcengineering/workbench' import { workbenchId } from '@hcengineering/workbench'
import workbench from '@hcengineering/workbench-resources/src/plugin' import workbench from '@hcengineering/workbench-resources/src/plugin'
import type { ActionCategory, ViewActionAvailabilityFunction } from '@hcengineering/view'
export default mergeIds(workbenchId, workbench, { export default mergeIds(workbenchId, workbench, {
component: { component: {
@ -30,6 +31,15 @@ export default mergeIds(workbenchId, workbench, {
}, },
function: { function: {
HasArchiveSpaces: '' as Resource<(spaces: Space[]) => Promise<boolean>>, HasArchiveSpaces: '' as Resource<(spaces: Space[]) => Promise<boolean>>,
IsOwner: '' as Resource<(docs: Doc[]) => Promise<boolean>> IsOwner: '' as Resource<(docs: Doc[]) => Promise<boolean>>,
CanCloseTab: '' as Resource<ViewActionAvailabilityFunction<Doc>>
},
category: {
Workbench: '' as Ref<ActionCategory>
},
actionImpl: {
PinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise<void>>,
UnpinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise<void>>,
CloseTab: '' as Resource<(doc?: Doc | Doc[]) => Promise<void>>
} }
}) })

View File

@ -292,6 +292,8 @@
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
border-radius: var(--small-focus-BorderRadius); border-radius: var(--small-focus-BorderRadius);
border-top-right-radius: 0;
border-bottom-right-radius:0 ;
&:not(.rowContent) { flex-direction: column; } &:not(.rowContent) { flex-direction: column; }
.panel-instance & { .panel-instance & {

View File

@ -150,6 +150,7 @@
.icon { .icon {
writing-mode: initial; writing-mode: initial;
text-orientation: initial; text-orientation: initial;
margin: 0;
&.vertical { &.vertical {
transform: rotate(90deg); transform: rotate(90deg);
text-orientation: upright; text-orientation: upright;
@ -158,6 +159,10 @@
.close-button { .close-button {
display: flex; display: flex;
&.horizontal {
margin: 0;
}
&.vertical { &.vertical {
transform: rotate(90deg); transform: rotate(90deg);
} }

View File

@ -208,6 +208,8 @@
z-index: 401; z-index: 401;
position: fixed; position: fixed;
background-color: transparent; background-color: transparent;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@media print { @media print {
position: static; position: static;

View File

@ -161,7 +161,7 @@
</button> </button>
</div> </div>
{/if} {/if}
<div class="flex-row-center" style:-webkit-app-region={'no-drag'}> <div class="flex-row-center left-items" style:-webkit-app-region={'no-drag'}>
<RootBarExtension position="left" /> <RootBarExtension position="left" />
</div> </div>
<div <div
@ -267,4 +267,8 @@
} }
} }
} }
.left-items {
overflow-x: auto;
}
</style> </style>

View File

@ -11,9 +11,11 @@
} }
oldLoc = newLocation.path[0] oldLoc = newLocation.path[0]
}) })
$: sorted = $rootBarExtensions.sort((a, b) => a[1].order - b[1].order)
</script> </script>
{#each $rootBarExtensions as ext (ext[1].id)} {#each sorted as ext (ext[1].id)}
{#if ext[0] === position} {#if ext[0] === position}
<div id={ext[1].id} style:margin-right={'1px'}> <div id={ext[1].id} style:margin-right={'1px'}>
<Component <Component

View File

@ -306,6 +306,7 @@ Array<
id: string id: string
component: AnyComponent | AnySvelteComponent component: AnyComponent | AnySvelteComponent
props?: Record<string, any> props?: Record<string, any>
order: number
} }
] ]
> >
@ -331,14 +332,15 @@ export async function formatDuration (duration: number, language: string): Promi
return text return text
} }
export function pushRootBarComponent (pos: 'left' | 'right', component: AnyComponent): void { export function pushRootBarComponent (pos: 'left' | 'right', component: AnyComponent, order?: number): void {
rootBarExtensions.update((cur) => { rootBarExtensions.update((cur) => {
if (cur.find((p) => p[1].component === component) === undefined) { if (cur.find((p) => p[1].component === component) === undefined) {
cur.push([ cur.push([
pos, pos,
{ {
id: component, id: component,
component component,
order: order ?? 1000
} }
]) ])
} }
@ -369,6 +371,7 @@ export function pushRootBarProgressComponent (
{ {
id, id,
component: RootStatusComponent, component: RootStatusComponent,
order: 10,
props: { props: {
label, label,
onProgress, onProgress,

View File

@ -0,0 +1,90 @@
<!--
// 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 { WorkbenchTab } from '@hcengineering/workbench'
import {
getNotificationsCount,
InboxNotificationsClientImpl,
isActivityNotification,
isMentionNotification,
NotifyMarker
} from '@hcengineering/notification-resources'
import { getClient } from '@hcengineering/presentation'
import { InboxNotification } from '@hcengineering/notification'
import { onDestroy } from 'svelte'
import { concatLink, Doc, Ref } from '@hcengineering/core'
import view, { decodeObjectURI } from '@hcengineering/view'
import { chunterId } from '@hcengineering/chunter'
import { parseLinkId } from '@hcengineering/view-resources'
import { parseLocation } from '@hcengineering/ui'
import chunter from '../plugin'
export let tab: WorkbenchTab
const client = getClient()
const hierarchy = client.getHierarchy()
const notificationClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = notificationClient.contextByDoc
let objectId: Ref<Doc> | undefined = undefined
let notifications: InboxNotification[] = []
let count = 0
$: context = objectId !== undefined ? $contextByDocStore.get(objectId) : undefined
$: void updateObjectId(tab)
async function updateObjectId (tab: WorkbenchTab): Promise<void> {
const base = `${window.location.protocol}//${window.location.host}`
const url = new URL(concatLink(base, tab.location))
const loc = parseLocation(url)
if (loc.path[2] !== chunterId) {
objectId = undefined
return
}
const client = getClient()
const providers = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const [id, _class] = decodeObjectURI(loc.path[3])
objectId = await parseLinkId(providers, id, _class)
}
const unsubscribe = notificationClient.inboxNotificationsByContext.subscribe((res) => {
if (context === undefined) {
count = 0
return
}
notifications = (res.get(context._id) ?? []).filter((n) => {
if (isActivityNotification(n)) return true
return isMentionNotification(n) && hierarchy.isDerived(n.mentionedInClass, chunter.class.ChatMessage)
})
})
$: void getNotificationsCount(context, notifications).then((res) => {
count = res
})
onDestroy(() => {
unsubscribe()
})
</script>
{#if count > 0}
<NotifyMarker kind="simple" size="xx-small" />
{/if}

View File

@ -52,6 +52,7 @@ import ThreadView from './components/threads/ThreadView.svelte'
import ThreadViewPanel from './components/threads/ThreadViewPanel.svelte' import ThreadViewPanel from './components/threads/ThreadViewPanel.svelte'
import ChatWidget from './components/ChatWidget.svelte' import ChatWidget from './components/ChatWidget.svelte'
import ChatWidgetTab from './components/ChatWidgetTab.svelte' import ChatWidgetTab from './components/ChatWidgetTab.svelte'
import WorkbenchTabExtension from './components/WorkbenchTabExtension.svelte'
import { import {
chunterSpaceLinkFragmentProvider, chunterSpaceLinkFragmentProvider,
@ -59,6 +60,7 @@ import {
getMessageLink, getMessageLink,
getMessageLocation, getMessageLocation,
getThreadLink, getThreadLink,
locationDataResolver,
openChannelInSidebar, openChannelInSidebar,
openChannelInSidebarAction, openChannelInSidebarAction,
openThreadInSidebar, openThreadInSidebar,
@ -178,7 +180,8 @@ export default async (): Promise<Resources> => ({
ChatMessagePreview, ChatMessagePreview,
JoinChannelNotificationPresenter, JoinChannelNotificationPresenter,
ChatWidget, ChatWidget,
ChatWidgetTab ChatWidgetTab,
WorkbenchTabExtension
}, },
activity: { activity: {
ChannelCreatedMessage, ChannelCreatedMessage,
@ -203,7 +206,8 @@ export default async (): Promise<Resources> => ({
CloseChatWidgetTab: closeChatWidgetTab, CloseChatWidgetTab: closeChatWidgetTab,
OpenChannelInSidebar: openChannelInSidebar, OpenChannelInSidebar: openChannelInSidebar,
CanTranslateMessage: canTranslateMessage, CanTranslateMessage: canTranslateMessage,
OpenThreadInSidebar: openThreadInSidebar OpenThreadInSidebar: openThreadInSidebar,
LocationDataResolver: locationDataResolver
}, },
actionImpl: { actionImpl: {
ArchiveChannel, ArchiveChannel,

View File

@ -4,7 +4,8 @@ import {
getCurrentResolvedLocation, getCurrentResolvedLocation,
getLocation, getLocation,
type Location, type Location,
navigate navigate,
languageStore
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { type Ref, type Doc, type Class, generateId } from '@hcengineering/core' import { type Ref, type Doc, type Class, generateId } from '@hcengineering/core'
import activity, { type ActivityMessage } from '@hcengineering/activity' import activity, { type ActivityMessage } from '@hcengineering/activity'
@ -16,12 +17,12 @@ import {
type ThreadMessage type ThreadMessage
} from '@hcengineering/chunter' } from '@hcengineering/chunter'
import { type DocNotifyContext, notificationId } from '@hcengineering/notification' import { type DocNotifyContext, notificationId } from '@hcengineering/notification'
import workbench, { type Widget, workbenchId } from '@hcengineering/workbench' import workbench, { type Widget, workbenchId, type LocationData } from '@hcengineering/workbench'
import { classIcon, getObjectLinkId } from '@hcengineering/view-resources' import { classIcon, getObjectLinkId, parseLinkId } from '@hcengineering/view-resources'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import view, { encodeObjectURI, decodeObjectURI } from '@hcengineering/view' import view, { encodeObjectURI, decodeObjectURI } from '@hcengineering/view'
import { createWidgetTab, isElementFromSidebar, sidebarStore } from '@hcengineering/workbench-resources' import { createWidgetTab, isElementFromSidebar, sidebarStore } from '@hcengineering/workbench-resources'
import { type Asset, translate } from '@hcengineering/platform' import { type Asset, type IntlString, translate } from '@hcengineering/platform'
import contact from '@hcengineering/contact' import contact from '@hcengineering/contact'
import { get } from 'svelte/store' import { get } from 'svelte/store'
@ -385,3 +386,73 @@ export function closeChatWidgetTab (tab?: ChatWidgetTab): void {
removeThreadFromLoc(tab.data.thread) removeThreadFromLoc(tab.data.thread)
} }
} }
export async function locationDataResolver (loc: Location): Promise<LocationData> {
const point = loc.path[3]
if (point == null || point === '') {
return { name: await translate(chunter.string.Chat, {}, get(languageStore)) }
}
const specialsData: Record<
string,
{
label: IntlString
icon: Asset
}
> = {
threads: {
label: chunter.string.Threads,
icon: chunter.icon.Chunter
// icon: chunter.icon.Thread
},
saved: {
label: chunter.string.Saved,
icon: chunter.icon.Chunter
// icon: chunter.icon.Bookmarks
},
chunterBrowser: {
label: chunter.string.ChunterBrowser,
icon: chunter.icon.Chunter
// icon: chunter.icon.ChunterBrowser
},
channels: {
label: chunter.string.Channels,
icon: chunter.icon.Chunter
// icon: chunter.icon.Hashtag
}
}
const specialData = specialsData[point]
if (specialData !== undefined) {
return { name: await translate(specialData.label, {}, get(languageStore)), icon: specialData.icon }
}
const client = getClient()
const hierarchy = client.getHierarchy()
const [id, _class] = decodeObjectURI(loc.path[3])
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const _id: Ref<Doc> | undefined = await parseLinkId(linkProviders, id, _class)
const object = await client.findOne(_class, { _id })
if (object === undefined) return { name: await translate(chunter.string.Chat, {}, get(languageStore)) }
const titleIntl = client.getHierarchy().getClass(object._class).label
const iconMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIcon)
const isDirect = hierarchy.isDerived(_class, chunter.class.DirectMessage)
const isChunterSpace = hierarchy.isDerived(_class, chunter.class.ChunterSpace)
const name = (await getChannelName(_id, _class, object)) ?? (await translate(titleIntl, {}))
return {
name,
icon: chunter.icon.Chunter,
iconComponent: isChunterSpace ? iconMixin?.component : undefined,
iconProps: {
_id: object._id,
value: object,
size: isDirect ? 'tiny' : 'x-small'
}
}
}

View File

@ -59,6 +59,7 @@
"@hcengineering/ui": "^0.6.15", "@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13", "@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0", "@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"svelte": "^4.2.12" "svelte": "^4.2.12"
} }
} }

View File

@ -144,7 +144,8 @@ import {
getCurrentEmployeePosition, getCurrentEmployeePosition,
getPersonTooltip, getPersonTooltip,
grouppingPersonManager, grouppingPersonManager,
resolveLocation resolveLocation,
resolveLocationData
} from './utils' } from './utils'
export * from './utils' export * from './utils'
@ -444,7 +445,8 @@ export default async (): Promise<Resources> => ({
PersonFilterFunction: filterPerson PersonFilterFunction: filterPerson
}, },
resolver: { resolver: {
Location: resolveLocation Location: resolveLocation,
LocationData: resolveLocationData
}, },
aggregation: { aggregation: {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method

View File

@ -50,7 +50,7 @@ import core, {
type WithLookup type WithLookup
} from '@hcengineering/core' } from '@hcengineering/core'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification' import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
import { getEmbeddedLabel, getResource, translate } from '@hcengineering/platform' import { type IntlString, getEmbeddedLabel, getResource, translate } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { type TemplateDataProvider } from '@hcengineering/templates' import { type TemplateDataProvider } from '@hcengineering/templates'
import { import {
@ -64,6 +64,7 @@ import {
import view, { type Filter, type GrouppingManager } from '@hcengineering/view' import view, { type Filter, type GrouppingManager } from '@hcengineering/view'
import { accessDeniedStore, FilterQuery } from '@hcengineering/view-resources' import { accessDeniedStore, FilterQuery } from '@hcengineering/view-resources'
import { derived, get, writable } from 'svelte/store' import { derived, get, writable } from 'svelte/store'
import { type LocationData } from '@hcengineering/workbench'
import contact from './plugin' import contact from './plugin'
import { personStore } from '.' import { personStore } from '.'
@ -536,3 +537,30 @@ export function groupPersonAccountValuesWithEmpty (
} }
return personAccountList.map((it) => it._id) return personAccountList.map((it) => it._id)
} }
export async function resolveLocationData (loc: Location): Promise<LocationData> {
const special = loc.path[3]
const specialsData: Record<string, IntlString> = {
companies: contact.string.Organizations,
employees: contact.string.Employees,
persons: contact.string.Persons
}
if (special == null) {
return { nameIntl: contact.string.Contacts }
}
const specialLabel = specialsData[special]
if (specialLabel !== undefined) {
return { nameIntl: specialLabel }
}
const client = getClient()
const object = await client.findOne(contact.class.Contact, { _id: special as Ref<Contact> })
if (object === undefined) {
return { nameIntl: specialLabel }
}
return { name: getName(client.getHierarchy(), object) }
}

View File

@ -299,7 +299,9 @@ export const contactPlugin = plugin(contactId, {
SelectUsers: '' as IntlString, SelectUsers: '' as IntlString,
AddGuest: '' as IntlString, AddGuest: '' as IntlString,
Members: '' as IntlString, Members: '' as IntlString,
Contacts: '' as IntlString Contacts: '' as IntlString,
Employees: '' as IntlString,
Persons: '' as IntlString
}, },
viewlet: { viewlet: {
TableMember: '' as Ref<Viewlet>, TableMember: '' as Ref<Viewlet>,

View File

@ -175,7 +175,10 @@ export function getDocumentLinkId (doc: Document): string {
return `${slug}-${doc._id}` return `${slug}-${doc._id}`
} }
export function parseDocumentId (shortLink: string): Ref<Document> | undefined { export function parseDocumentId (shortLink?: string): Ref<Document> | undefined {
if (shortLink === undefined) {
return undefined
}
const parts = shortLink.split('-') const parts = shortLink.split('-')
if (parts.length > 1) { if (parts.length > 1) {
return parts[parts.length - 1] as Ref<Document> return parts[parts.length - 1] as Ref<Document>

View File

@ -34,7 +34,7 @@
} }
onMount(() => { onMount(() => {
pushRootBarComponent('left', love.component.ControlExt) pushRootBarComponent('left', love.component.ControlExt, 20)
lk.on(RoomEvent.TrackSubscribed, handleTrackSubscribed) lk.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed) lk.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
}) })

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Widget preferences", "WidgetPreferences": "Widget preferences",
"OpenInSidebar": "Open in sidebar", "OpenInSidebar": "Open in sidebar",
"OpenInSidebarNewTab": "Open in sidebar new tab", "OpenInSidebarNewTab": "Open in sidebar new tab",
"ConfigureWidgets": "Configure widgets" "ConfigureWidgets": "Configure widgets",
"Tab": "Tab"
} }
} }

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Preferencias del widget", "WidgetPreferences": "Preferencias del widget",
"OpenInSidebar": "Abrir en la barra lateral", "OpenInSidebar": "Abrir en la barra lateral",
"OpenInSidebarNewTab": "Abrir en una nueva pestaña de la barra lateral", "OpenInSidebarNewTab": "Abrir en una nueva pestaña de la barra lateral",
"ConfigureWidgets": "Configurar widgets" "ConfigureWidgets": "Configurar widgets",
"Tab": "Pestaña"
} }
} }

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Préférences du widget", "WidgetPreferences": "Préférences du widget",
"OpenInSidebar": "Ouvrir dans la barre latérale", "OpenInSidebar": "Ouvrir dans la barre latérale",
"OpenInSidebarNewTab": "Ouvrir dans un nouvel onglet de la barre latérale", "OpenInSidebarNewTab": "Ouvrir dans un nouvel onglet de la barre latérale",
"ConfigureWidgets": "Configurer les widgets" "ConfigureWidgets": "Configurer les widgets",
"Tab": "Onglet"
} }
} }

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Preferências do widget", "WidgetPreferences": "Preferências do widget",
"OpenInSidebar": "Abrir na barra lateral", "OpenInSidebar": "Abrir na barra lateral",
"OpenInSidebarNewTab": "Abrir em uma nova aba da barra lateral", "OpenInSidebarNewTab": "Abrir em uma nova aba da barra lateral",
"ConfigureWidgets": "Configurar widgets" "ConfigureWidgets": "Configurar widgets",
"Tab": "Aba"
} }
} }

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Настройки виджета", "WidgetPreferences": "Настройки виджета",
"OpenInSidebar": "Открыть в боковой панели", "OpenInSidebar": "Открыть в боковой панели",
"OpenInSidebarNewTab": "Открыть в новой вкладке боковой панели", "OpenInSidebarNewTab": "Открыть в новой вкладке боковой панели",
"ConfigureWidgets": "Настроить виджеты" "ConfigureWidgets": "Настроить виджеты",
"Tab": "Вкладка"
} }
} }

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "小部件首选项", "WidgetPreferences": "小部件首选项",
"OpenInSidebar": "在侧边栏中打开", "OpenInSidebar": "在侧边栏中打开",
"OpenInSidebarNewTab": "在侧边栏新标签页中打开", "OpenInSidebarNewTab": "在侧边栏新标签页中打开",
"ConfigureWidgets": "配置小部件" "ConfigureWidgets": "配置小部件",
"Tab": "选项卡"
} }
} }

View File

@ -16,11 +16,20 @@
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import contact, { PersonAccount } from '@hcengineering/contact' import contact, { PersonAccount } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources' 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 login from '@hcengineering/login'
import notification, { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification' import notification, { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification'
import { BrowserNotificatator, InboxNotificationsClientImpl } from '@hcengineering/notification-resources' import { BrowserNotificatator, InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { IntlString, broadcastEvent, getMetadata, getResource } from '@hcengineering/platform' import { broadcastEvent, getMetadata, getResource, IntlString } from '@hcengineering/platform'
import { import {
ActionContext, ActionContext,
ComponentExtensions, ComponentExtensions,
@ -30,55 +39,62 @@
reduceCalls reduceCalls
} from '@hcengineering/presentation' } from '@hcengineering/presentation'
import setting from '@hcengineering/setting' import setting from '@hcengineering/setting'
import support, { SupportStatus, supportLink } from '@hcengineering/support' import support, { supportLink, SupportStatus } from '@hcengineering/support'
import { import {
AnyComponent, AnyComponent,
areLocationsEqual,
Button, Button,
closePanel,
closePopup,
closeTooltip,
CompAndProps, CompAndProps,
Component, Component,
defineSeparators,
deviceOptionsStore as deviceInfo,
Dock, Dock,
getCurrentLocation,
getLocation,
IconSettings, IconSettings,
Label, Label,
Location, Location,
location,
locationStorageKeyId,
locationToUrl,
mainSeparators,
navigate,
openPanel,
PanelInstance, PanelInstance,
Popup, Popup,
PopupAlignment, PopupAlignment,
PopupPosAlignment, PopupPosAlignment,
PopupResult, PopupResult,
ResolvedLocation,
Separator,
TooltipInstance,
areLocationsEqual,
closePanel,
closePopup,
closeTooltip,
defineSeparators,
deviceOptionsStore as deviceInfo,
getCurrentLocation,
getLocation,
location,
locationStorageKeyId,
navigate,
openPanel,
popupstore, popupstore,
pushRootBarComponent, pushRootBarComponent,
ResolvedLocation,
resolvedLocationStore, resolvedLocationStore,
Separator,
setResolvedLocation, setResolvedLocation,
showPopup, showPopup,
workbenchSeparators, TooltipInstance,
mainSeparators workbenchSeparators
} from '@hcengineering/ui' } from '@hcengineering/ui'
import view from '@hcengineering/view' import view from '@hcengineering/view'
import { import {
accessDeniedStore,
ActionHandler, ActionHandler,
ListSelectionProvider, ListSelectionProvider,
NavLink,
accessDeniedStore,
migrateViewOpttions, migrateViewOpttions,
NavLink,
parseLinkId, parseLinkId,
updateFocus updateFocus
} from '@hcengineering/view-resources' } 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 { getContext, onDestroy, onMount, tick } from 'svelte'
import { subscribeMobile } from '../mobile' import { subscribeMobile } from '../mobile'
import workbench from '../plugin' import workbench from '../plugin'
@ -96,6 +112,7 @@
import TopMenu from './icons/TopMenu.svelte' import TopMenu from './icons/TopMenu.svelte'
import WidgetsBar from './sidebar/Sidebar.svelte' import WidgetsBar from './sidebar/Sidebar.svelte'
import { sidebarStore, SidebarVariant, syncSidebarState } from '../sidebar' import { sidebarStore, SidebarVariant, syncSidebarState } from '../sidebar'
import { getTabLocation, selectTab, syncWorkbenchTab, tabIdStore, tabsStore } from '../workbench'
let contentPanel: HTMLElement 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(() => { onMount(() => {
pushRootBarComponent('right', view.component.SearchSelector) pushRootBarComponent('right', view.component.SearchSelector)
pushRootBarComponent('left', workbench.component.WorkbenchTabs, 30)
void getResource(login.function.GetWorkspaces).then(async (getWorkspaceFn) => { void getResource(login.function.GetWorkspaces).then(async (getWorkspaceFn) => {
$workspacesStore = await getWorkspaceFn() $workspacesStore = await getWorkspaceFn()
await updateWindowTitle(getLocation()) await updateWindowTitle(getLocation())
}) })
syncSidebarState() syncSidebarState()
syncWorkbenchTab()
}) })
const account = getCurrentAccount() as PersonAccount const account = getCurrentAccount() as PersonAccount
@ -296,7 +374,13 @@
async function syncLoc (loc: Location): Promise<void> { async function syncLoc (loc: Location): Promise<void> {
accessDeniedStore.set(false) accessDeniedStore.set(false)
const originalLoc = JSON.stringify(loc) 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) { if (loc.path.length > 3 && getSpecialComponent(loc.path[3]) === undefined) {
// resolve short links // resolve short links
const resolvedLoc = await resolveShortLink(loc) const resolvedLoc = await resolveShortLink(loc)

View File

@ -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>

View File

@ -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>

View File

@ -23,7 +23,9 @@ import WorkbenchApp from './components/WorkbenchApp.svelte'
import { doNavigate } from './utils' import { doNavigate } from './utils'
import Workbench from './components/Workbench.svelte' import Workbench from './components/Workbench.svelte'
import ServerManager from './components/ServerManager.svelte' import ServerManager from './components/ServerManager.svelte'
import WorkbenchTabs from './components/WorkbenchTabs.svelte'
import { isAdminUser } from '@hcengineering/presentation' import { isAdminUser } from '@hcengineering/presentation'
import { canCloseTab, closeTab, pinTab, unpinTab } from './workbench'
async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> { async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> {
return spaces.find((sp) => sp.archived) !== undefined return spaces.find((sp) => sp.archived) !== undefined
@ -46,13 +48,18 @@ export default async (): Promise<Resources> => ({
SpacePanel, SpacePanel,
SpecialView, SpecialView,
Workbench, Workbench,
ServerManager ServerManager,
WorkbenchTabs
}, },
function: { function: {
HasArchiveSpaces: hasArchiveSpaces, HasArchiveSpaces: hasArchiveSpaces,
IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser() IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser(),
CanCloseTab: canCloseTab
}, },
actionImpl: { actionImpl: {
Navigate: doNavigate Navigate: doNavigate,
PinTab: pinTab,
UnpinTab: unpinTab,
CloseTab: closeTab
} }
}) })

View File

@ -47,13 +47,15 @@ export default mergeIds(workbenchId, workbench, {
WorkspaceCreating: '' as IntlString, WorkspaceCreating: '' as IntlString,
AccessDenied: '' as IntlString, AccessDenied: '' as IntlString,
Widget: '' as IntlString, Widget: '' as IntlString,
WidgetPreference: '' as IntlString WidgetPreference: '' as IntlString,
Tab: '' as IntlString
}, },
metadata: { metadata: {
MobileAllowed: '' as Metadata<boolean> MobileAllowed: '' as Metadata<boolean>
}, },
component: { component: {
SpacePanel: '' as AnyComponent, SpacePanel: '' as AnyComponent,
Workbench: '' as AnyComponent Workbench: '' as AnyComponent,
WorkbenchTabs: '' as AnyComponent
} }
}) })

View File

@ -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 })
}

View File

@ -30,6 +30,15 @@ import { ViewAction } from '@hcengineering/view'
/** /**
* @public * @public
*/ */
export interface LocationData {
name?: string
nameIntl?: IntlString
icon?: Asset
iconComponent?: AnyComponent
iconProps?: Record<string, any>
}
export interface Application extends Doc { export interface Application extends Doc {
label: IntlString label: IntlString
alias: string alias: string
@ -42,6 +51,7 @@ export interface Application extends Doc {
aside?: AnyComponent aside?: AnyComponent
locationResolver?: Resource<(loc: Location) => Promise<ResolvedLocation | undefined>> locationResolver?: Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
locationDataResolver?: Resource<(loc: Location) => Promise<LocationData>>
// Component will be displayed in case navigator model is not defined, or nothing is selected in navigator model // Component will be displayed in case navigator model is not defined, or nothing is selected in navigator model
component?: AnyComponent component?: AnyComponent
@ -102,6 +112,11 @@ export interface TxSidebarEvent<T extends Record<string, any> = Record<string, a
params: T params: T
} }
export interface WorkbenchTab extends Preference {
location: string
isPinned: boolean
}
/** /**
* @public * @public
*/ */
@ -201,7 +216,8 @@ export default plugin(workbenchId, {
HiddenApplication: '' as Ref<Class<HiddenApplication>>, HiddenApplication: '' as Ref<Class<HiddenApplication>>,
Widget: '' as Ref<Class<Widget>>, Widget: '' as Ref<Class<Widget>>,
WidgetPreference: '' as Ref<Class<WidgetPreference>>, WidgetPreference: '' as Ref<Class<WidgetPreference>>,
TxSidebarEvent: '' as Ref<Class<TxSidebarEvent<Record<string, any>>>> TxSidebarEvent: '' as Ref<Class<TxSidebarEvent<Record<string, any>>>>,
WorkbenchTab: '' as Ref<Class<WorkbenchTab>>
}, },
mixin: { mixin: {
SpaceView: '' as Ref<Mixin<SpaceView>> SpaceView: '' as Ref<Mixin<SpaceView>>
@ -238,7 +254,8 @@ export default plugin(workbenchId, {
NavigationExpandedDefault: '' as Metadata<boolean> NavigationExpandedDefault: '' as Metadata<boolean>
}, },
extensions: { extensions: {
WorkbenchExtensions: '' as ComponentExtensionId WorkbenchExtensions: '' as ComponentExtensionId,
WorkbenchTabExtensions: '' as ComponentExtensionId
}, },
actionImpl: { actionImpl: {
Navigate: '' as ViewAction<{ Navigate: '' as ViewAction<{