refactor: deprecate legacy packages and clean up build scripts (#5162)

* refactor: deprecate legacy packages and clean up build scripts

* chore: remove joi publish workflow

* chore: core publish run on dispatch only

* chore: correct version bump on web package

* chore: make dev for tauri target
This commit is contained in:
Louis 2025-06-02 14:29:17 +07:00 committed by GitHub
parent 5d070f6a17
commit 38c9cf9a68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
563 changed files with 39 additions and 41371 deletions

View File

@ -1,10 +1,9 @@
name: Publish core Package to npmjs
on:
push:
tags: ["v[0-9]+.[0-9]+.[0-9]+-core"]
paths: ["core/**", ".github/workflows/publish-npm-core.yml"]
pull_request:
paths: ["core/**", ".github/workflows/publish-npm-core.yml"]
tags: ['v[0-9]+.[0-9]+.[0-9]+-core']
paths: ['core/**', '.github/workflows/publish-npm-core.yml']
workflow_dispatch:
jobs:
build-and-publish-plugins:
environment: production
@ -12,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: "0"
fetch-depth: '0'
token: ${{ secrets.PAT_SERVICE_ACCOUNT }}
- name: Install jq
@ -24,7 +23,7 @@ jobs:
env:
GITHUB_REF: ${{ github.ref }}
- name: "Get Semantic Version from tag"
- name: 'Get Semantic Version from tag'
if: github.event_name == 'push'
run: |
# Get the tag from the event
@ -42,8 +41,8 @@ jobs:
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v3
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- run: cd core && corepack enable && corepack prepare yarn@4.5.3 --activate && yarn --version && yarn config set -H enableImmutableInstalls false && yarn install && yarn build

View File

@ -1,53 +0,0 @@
name: Publish joi Package to npmjs
on:
push:
tags: ["v[0-9]+.[0-9]+.[0-9]+-joi"]
paths: ["joi/**", ".github/workflows/publish-npm-joi.yml"]
pull_request:
paths: ["joi/**", ".github/workflows/publish-npm-joi.yml"]
jobs:
build-and-publish-plugins:
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: "0"
token: ${{ secrets.PAT_SERVICE_ACCOUNT }}
- name: Install jq
uses: dcarbone/install-jq-action@v2.0.1
- name: Extract tag name without v prefix
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV && echo "::set-output name=version::${GITHUB_REF#refs/tags/v}"
env:
GITHUB_REF: ${{ github.ref }}
- name: "Get Semantic Version from tag"
if: github.event_name == 'push'
run: |
# Get the tag from the event
tag=${GITHUB_REF#refs/tags/v}
# remove the -joi suffix
new_version=$(echo $tag | sed -n 's/-joi//p')
echo $new_version
# Replace the old version with the new version in package.json
jq --arg version "$new_version" '.version = $version' joi/package.json > /tmp/package.json && mv /tmp/package.json joi/package.json
# Print the new version
echo "Updated package.json version to: $new_version"
cat joi/package.json
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v3
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
- run: cd joi && corepack enable && corepack prepare yarn@4.5.3 --activate && yarn --version && yarn config set -H enableImmutableInstalls false && yarn install && yarn build
- run: cd joi && yarn publish --access public
if: github.event_name == 'push'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -119,8 +119,8 @@ jobs:
"usr/lib/Jan/binaries/libvulkan.so": "binaries/libvulkan.so"}' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
fi
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
mv /tmp/package.json web-app/package.json
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
cat ./src-tauri/Cargo.toml

View File

@ -120,8 +120,8 @@ jobs:
# Update tauri.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version | .bundle.createUpdaterArtifacts = true' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
mv /tmp/package.json web-app/package.json
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
cat ./src-tauri/Cargo.toml

View File

@ -97,8 +97,8 @@ jobs:
# Update tauri.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version | .bundle.createUpdaterArtifacts = true | .bundle.windows.nsis.template = "tauri.bundle.windows.nsis.template"' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
mv /tmp/tauri.conf.json ./src-tauri/tauri.conf.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
mv /tmp/package.json web-app/package.json
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
echo "---------Cargo.toml---------"

View File

@ -24,88 +24,26 @@ ifeq ($(OS),Windows_NT)
echo "skip"
endif
yarn install
yarn build:joi
yarn build:core
yarn build:extensions
check-file-counts: install-and-build
ifeq ($(OS),Windows_NT)
powershell -Command "if ((Get-ChildItem -Path pre-install -Filter *.tgz | Measure-Object | Select-Object -ExpandProperty Count) -ne (Get-ChildItem -Path extensions -Directory | Where-Object Name -like *-extension* | Measure-Object | Select-Object -ExpandProperty Count)) { Write-Host 'Number of .tgz files in pre-install does not match the number of subdirectories in extensions with package.json'; exit 1 } else { Write-Host 'Extension build successful' }"
else
@tgz_count=$$(find pre-install -type f -name "*.tgz" | wc -l); dir_count=$$(find extensions -mindepth 1 -maxdepth 1 -type d -exec test -e '{}/package.json' \; -print | wc -l); if [ $$tgz_count -ne $$dir_count ]; then echo "Number of .tgz files in pre-install ($$tgz_count) does not match the number of subdirectories in extension ($$dir_count)"; exit 1; else echo "Extension build successful"; fi
endif
dev: check-file-counts
yarn dev
dev-tauri: check-file-counts
dev: install-and-build
yarn install:cortex
yarn download:bin
yarn copy:lib
CLEAN=true yarn dev:tauri
yarn dev
# Deprecated soon
dev-tauri: install-and-build
yarn install:cortex
yarn download:bin
yarn copy:lib
yarn dev:tauri
# Linting
lint: check-file-counts
lint: install-and-build
yarn lint
update-playwright-config:
ifeq ($(OS),Windows_NT)
echo -e "const RPconfig = {\n\
apiKey: '$(REPORT_PORTAL_API_KEY)',\n\
endpoint: '$(REPORT_PORTAL_URL)',\n\
project: '$(REPORT_PORTAL_PROJECT_NAME)',\n\
launch: '$(REPORT_PORTAL_LAUNCH_NAME)',\n\
attributes: [\n\
{\n\
key: 'key',\n\
value: 'value',\n\
},\n\
{\n\
value: 'value',\n\
},\n\
],\n\
description: '$(REPORT_PORTAL_DESCRIPTION)',\n\
}\n$$(cat electron/playwright.config.ts)" > electron/playwright.config.ts;
sed -i "s/^ reporter: .*/ reporter: [['@reportportal\/agent-js-playwright', RPconfig]],/" electron/playwright.config.ts
else ifeq ($(shell uname -s),Linux)
echo "const RPconfig = {\n\
apiKey: '$(REPORT_PORTAL_API_KEY)',\n\
endpoint: '$(REPORT_PORTAL_URL)',\n\
project: '$(REPORT_PORTAL_PROJECT_NAME)',\n\
launch: '$(REPORT_PORTAL_LAUNCH_NAME)',\n\
attributes: [\n\
{\n\
key: 'key',\n\
value: 'value',\n\
},\n\
{\n\
value: 'value',\n\
},\n\
],\n\
description: '$(REPORT_PORTAL_DESCRIPTION)',\n\
}\n$$(cat electron/playwright.config.ts)" > electron/playwright.config.ts;
sed -i "s/^ reporter: .*/ reporter: [['@reportportal\/agent-js-playwright', RPconfig]],/" electron/playwright.config.ts
else
echo "const RPconfig = {\n\
apiKey: '$(REPORT_PORTAL_API_KEY)',\n\
endpoint: '$(REPORT_PORTAL_URL)',\n\
project: '$(REPORT_PORTAL_PROJECT_NAME)',\n\
launch: '$(REPORT_PORTAL_LAUNCH_NAME)',\n\
attributes: [\n\
{\n\
key: 'key',\n\
value: 'value',\n\
},\n\
{\n\
value: 'value',\n\
},\n\
],\n\
description: '$(REPORT_PORTAL_DESCRIPTION)',\n\
}\n$$(cat electron/playwright.config.ts)" > electron/playwright.config.ts;
sed -i '' "s|^ reporter: .*| reporter: [['@reportportal\/agent-js-playwright', RPconfig]],|" electron/playwright.config.ts
endif
# Testing
test: lint
# yarn build:test
@ -114,16 +52,17 @@ test: lint
yarn test
# Builds and publishes the app
build-and-publish: check-file-counts
yarn build:publish
# Build
build: check-file-counts
build-and-publish: install-and-build
yarn build
build-tauri: check-file-counts
# Build
build: install-and-build
yarn build
# Deprecated soon
build-tauri: install-and-build
yarn copy:lib
yarn build-tauri
yarn build
clean:
ifeq ($(OS),Windows_NT)

View File

@ -1,46 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
env: {
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
],
rules: {
'react/prop-types': 'off', // In favor of strong typing - no need to dedupe
'react/no-is-mounted': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
settings: {
react: {
createClass: 'createReactClass', // Regex for Component Factory to use,
// default to "createReactClass"
pragma: 'React', // Pragma to use, default to "React"
version: 'detect', // React version. "detect" automatically picks the version you have installed.
// You can also use `16.0`, `16.3`, etc, if you want to override the detected value.
// default to latest and warns if missing
// It will default to "detect" in the future
},
linkComponents: [
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
'Hyperlink',
{ name: 'Link', linkAttribute: 'to' },
],
},
ignorePatterns: [
'build',
'renderer',
'node_modules',
'@global',
'playwright-report',
'test-data',
],
}

View File

@ -1,10 +0,0 @@
export {}
declare global {
namespace NodeJS {
interface Global {
core: any
}
}
var core: any | undefined
}

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@ -1,20 +0,0 @@
import { Handler, RequestHandler } from '@janhq/core/node'
import { ipcMain } from 'electron'
import { windowManager } from '../managers/window'
export function injectHandler() {
const ipcWrapper: Handler = (
route: string,
listener: (...args: any[]) => any
) =>
ipcMain.handle(route, async (_event, ...args: any[]) => {
return listener(...args)
})
const handler = new RequestHandler(
ipcWrapper,
(channel: string, args: any) =>
windowManager.mainWindow?.webContents.send(channel, args)
)
handler.handle()
}

View File

@ -1,327 +0,0 @@
import { app, ipcMain, dialog, shell, nativeTheme } from 'electron'
import { autoUpdater } from 'electron-updater'
import { join } from 'path'
import { windowManager } from '../managers/window'
import {
ModuleManager,
getJanDataFolderPath,
getJanExtensionsPath,
init,
AppEvent,
NativeRoute,
SelectFileProp,
} from '@janhq/core/node'
import { SelectFileOption } from '@janhq/core'
import { menu } from '../utils/menu'
import { migrate } from '../utils/migration'
import { createUserSpace } from '../utils/path'
import { setupExtensions } from '../utils/extension'
const isMac = process.platform === 'darwin'
export function handleAppIPCs() {
/**
* Handles the "openAppDirectory" IPC message by opening the app's user data directory.
* The `shell.openPath` method is used to open the directory in the user's default file explorer.
* @param _event - The IPC event object.
*/
ipcMain.handle(NativeRoute.openAppDirectory, async (_event) => {
shell.openPath(getJanDataFolderPath())
})
ipcMain.handle(NativeRoute.appUpdateDownload, async (_event) => {
autoUpdater.downloadUpdate()
})
/**
* Handles the "setNativeThemeLight" IPC message by setting the native theme source to "light".
* This will change the appearance of the app to the light theme.
*/
ipcMain.handle(NativeRoute.setNativeThemeLight, () => {
nativeTheme.themeSource = 'light'
})
/**
* Handles the "setCloseApp" IPC message by closing the main application window.
* This effectively closes the application if no other windows are open.
*/
ipcMain.handle(NativeRoute.setCloseApp, () => {
windowManager.mainWindow?.close()
})
/**
* Handles the "setMinimizeApp" IPC message by minimizing the main application window.
* The window will be minimized to the system's taskbar or dock.
*/
ipcMain.handle(NativeRoute.setMinimizeApp, () => {
windowManager.mainWindow?.minimize()
})
/**
* Handles the "setMaximizeApp" IPC message. It toggles the maximization state of the main window.
* If the window is currently maximized, it will be un-maximized (restored to its previous size).
* If the window is not maximized, it will be maximized to fill the screen.
* @param _event - The IPC event object.
*/
ipcMain.handle(NativeRoute.setMaximizeApp, async (_event) => {
if (windowManager.mainWindow?.isMaximized()) {
windowManager.mainWindow.unmaximize()
} else {
windowManager.mainWindow?.maximize()
}
})
/**
* Handles the "setNativeThemeDark" IPC message by setting the native theme source to "dark".
* This will change the appearance of the app to the dark theme.
*/
ipcMain.handle(NativeRoute.setNativeThemeDark, () => {
nativeTheme.themeSource = 'dark'
})
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
* @param url - The URL to open.
*/
ipcMain.handle(NativeRoute.openExternalUrl, async (_event, url) => {
shell.openExternal(url)
})
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
* @param url - The URL to open.
*/
ipcMain.handle(NativeRoute.openFileExplore, async (_event, url) => {
shell.openPath(url)
})
/**
* Relaunches the app in production - reload window in development.
* @param _event - The IPC event object.
* @param url - The URL to reload.
*/
ipcMain.handle(NativeRoute.relaunch, async (_event) => {
ModuleManager.instance.clearImportedModules()
if (app.isPackaged) {
app.relaunch()
app.exit()
} else {
for (const modulePath in ModuleManager.instance.requiredModules) {
delete require.cache[
require.resolve(join(getJanExtensionsPath(), modulePath))
]
}
init({
// Function to check from the main process that user wants to install a extension
confirmInstall: async (_extensions: string[]) => {
return true
},
// Path to install extension to
extensionsPath: getJanExtensionsPath(),
})
windowManager.mainWindow?.reload()
}
})
/**
* Handles the "selectDirectory" IPC message to open a dialog for selecting a directory.
* If no main window is found, logs an error and exits.
* @returns {string} The path of the selected directory, or nothing if canceled.
*/
ipcMain.handle(NativeRoute.selectDirectory, async () => {
const mainWindow = windowManager.mainWindow
if (!mainWindow) {
console.error('No main window found')
return
}
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Select a folder',
buttonLabel: 'Select Folder',
properties: ['openDirectory', 'createDirectory'],
})
if (canceled) {
return
} else {
return filePaths[0]
}
})
/**
* Handles the "selectFiles" IPC message to open a dialog for selecting files.
* Allows options for setting the dialog title, button label, and selection properties.
* Logs an error if no main window is found.
* @param _event - The IPC event object.
* @param option - Options for customizing file selection dialog.
* @returns {string[]} An array of selected file paths, or nothing if canceled.
*/
ipcMain.handle(
NativeRoute.selectFiles,
async (_event, option?: SelectFileOption) => {
const mainWindow = windowManager.mainWindow
if (!mainWindow) {
console.error('No main window found')
return
}
const title = option?.title ?? 'Select files'
const buttonLabel = option?.buttonLabel ?? 'Select'
const props: SelectFileProp[] = ['openFile']
if (option?.allowMultiple) {
props.push('multiSelections')
}
if (option?.selectDirectory) {
props.push('openDirectory')
}
console.debug(`Select files with props: ${props}`)
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title,
buttonLabel,
properties: props,
filters: option?.filters,
})
if (canceled) return
return filePaths
}
)
/**
* Handles the "hideQuickAskWindow" IPC message to hide the quick ask window.
* @returns A promise that resolves when the window is hidden.
*/
ipcMain.handle(
NativeRoute.hideQuickAskWindow,
async (): Promise<void> => windowManager.hideQuickAskWindow()
)
/**
* Handles the "sendQuickAskInput" IPC message to send user input to the main window.
* @param _event - The IPC event object.
* @param input - User input string to be sent.
*/
ipcMain.handle(
NativeRoute.sendQuickAskInput,
async (_event, input: string): Promise<void> => {
windowManager.mainWindow?.webContents.send(
AppEvent.onUserSubmitQuickAsk,
input
)
}
)
/**
* Handles the "showOpenMenu" IPC message to show the context menu at given coordinates.
* Only applicable on non-Mac platforms.
* @param e - The event object.
* @param args - Contains coordinates where the menu should appear.
*/
ipcMain.handle(NativeRoute.showOpenMenu, function (e, args) {
if (!isMac && windowManager.mainWindow) {
menu.popup({
window: windowManager.mainWindow,
x: args.x,
y: args.y,
})
}
})
/**
* Handles the "hideMainWindow" IPC message to hide the main application window.
* @returns A promise that resolves when the window is hidden.
*/
ipcMain.handle(
NativeRoute.hideMainWindow,
async (): Promise<void> => windowManager.hideMainWindow()
)
/**
* Handles the "showMainWindow" IPC message to show the main application window.
* @returns A promise that resolves when the window is shown.
*/
ipcMain.handle(
NativeRoute.showMainWindow,
async (): Promise<void> => windowManager.showMainWindow()
)
/**
* Handles the "quickAskSizeUpdated" IPC message to update the size of the quick ask window.
* Resizes window by the given height offset.
* @param _event - The IPC event object.
* @param heightOffset - The amount of height to increase.
* @returns A promise that resolves when the window is resized.
*/
ipcMain.handle(
NativeRoute.quickAskSizeUpdated,
async (_event, heightOffset: number): Promise<void> =>
windowManager.expandQuickAskWindow(heightOffset)
)
/**
* Handles the "ackDeepLink" IPC message to acknowledge a deep link.
* Triggers handling of deep link in the application.
* @param _event - The IPC event object.
* @returns A promise that resolves when the deep link is acknowledged.
*/
ipcMain.handle(NativeRoute.ackDeepLink, async (_event): Promise<void> => {
windowManager.ackDeepLink()
})
/**
* Handles the "factoryReset" IPC message to reset the application to its initial state.
* Clears loaded modules, recreates user space, runs migrations, and sets up extensions.
* @param _event - The IPC event object.
* @returns A promise that resolves after the reset operations are complete.
*/
ipcMain.handle(NativeRoute.factoryReset, async (_event): Promise<void> => {
ModuleManager.instance.clearImportedModules()
return createUserSpace().then(migrate).then(setupExtensions)
})
/**
* Handles the "startServer" IPC message to start the Jan API server.
* Initializes and starts server with provided configuration options.
* @param _event - The IPC event object.
* @param args - Configuration object containing host, port, CORS settings etc.
* @returns Promise that resolves when server starts successfully
*/
ipcMain.handle(
NativeRoute.startServer,
async (_event, args): Promise<void> => {
const { startServer } = require('@janhq/server')
return startServer({
host: args?.host,
port: args?.port,
isCorsEnabled: args?.isCorsEnabled,
isVerboseEnabled: args?.isVerboseEnabled,
prefix: args?.prefix,
})
}
)
/**
* Handles the "stopServer" IPC message to stop the Jan API server.
* Gracefully shuts down the server instance.
* @param _event - The IPC event object
* @returns Promise that resolves when server stops successfully
*/
ipcMain.handle(NativeRoute.stopServer, async (_event): Promise<void> => {
/**
* Stop Jan API Server.
*/
const { stopServer } = require('@janhq/server')
return stopServer()
})
/**
* Handles the "appToken" IPC message to generate a random app ID.
*/
ipcMain.handle(NativeRoute.appToken, async (_event): Promise<string> => {
return process.env.appToken ?? 'cortex.cpp'
})
}

View File

@ -1,70 +0,0 @@
import { app, dialog } from 'electron'
import { windowManager } from './../managers/window'
import {
ProgressInfo,
UpdateDownloadedEvent,
UpdateInfo,
autoUpdater,
} from 'electron-updater'
import { AppEvent } from '@janhq/core/node'
import { trayManager } from '../managers/tray'
export let waitingToInstallVersion: string | undefined = undefined
export function handleAppUpdates() {
/* Should not check for update during development */
if (!app.isPackaged) {
return
}
/* New Update Available */
autoUpdater.on('update-available', async (_info: UpdateInfo) => {
windowManager.mainWindow?.webContents.send(
AppEvent.onAppUpdateAvailable,
{}
)
})
/* App Update Completion Message */
autoUpdater.on('update-downloaded', async (_info: UpdateDownloadedEvent) => {
windowManager.mainWindow?.webContents.send(
AppEvent.onAppUpdateDownloadSuccess,
{}
)
const action = await dialog.showMessageBox({
message: `Update downloaded. Please restart the application to apply the updates.`,
buttons: ['Restart', 'Later'],
})
if (action.response === 0) {
trayManager.destroyCurrentTray()
windowManager.closeQuickAskWindow()
waitingToInstallVersion = _info?.version
autoUpdater.quitAndInstall()
}
})
/* App Update Error */
autoUpdater.on('error', (info: Error) => {
windowManager.mainWindow?.webContents.send(
AppEvent.onAppUpdateDownloadError,
{ failedToInstallVersion: waitingToInstallVersion, info }
)
})
/* App Update Progress */
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
console.debug('app update progress: ', progress.percent)
windowManager.mainWindow?.webContents.send(
AppEvent.onAppUpdateDownloadUpdate,
{
...progress,
}
)
})
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
if (process.env.CI !== 'e2e') {
autoUpdater.checkForUpdates()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@ -1,18 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
modulePathIgnorePatterns: ['<rootDir>/tests'],
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
},
runner: './testRunner.js',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
diagnostics: false,
},
],
},
}

View File

@ -1,161 +0,0 @@
import { app, BrowserWindow } from 'electron'
import { join, resolve } from 'path'
/**
* Managers
**/
import { windowManager } from './managers/window'
import { getAppConfigurations, log } from '@janhq/core/node'
/**
* IPC Handlers
**/
import { injectHandler } from './handlers/common'
import { handleAppUpdates } from './handlers/update'
import { handleAppIPCs } from './handlers/native'
/**
* Utils
**/
import { setupMenu } from './utils/menu'
import { createUserSpace } from './utils/path'
import { migrate } from './utils/migration'
import { cleanUpAndQuit } from './utils/clean'
import { setupExtensions } from './utils/extension'
import { setupCore } from './utils/setup'
import { setupReactDevTool } from './utils/dev'
import { trayManager } from './managers/tray'
import { logSystemInfo } from './utils/system'
import { registerGlobalShortcuts } from './utils/shortcut'
import { registerLogger } from './utils/logger'
import { randomBytes } from 'crypto'
const preloadPath = join(__dirname, 'preload.js')
const preloadQuickAskPath = join(__dirname, 'preload.quickask.js')
const rendererPath = join(__dirname, '..', 'renderer')
const quickAskPath = join(rendererPath, 'search.html')
const mainPath = join(rendererPath, 'index.html')
const mainUrl = 'http://localhost:3000'
const quickAskUrl = `${mainUrl}/search`
const gotTheLock = app.requestSingleInstanceLock()
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('jan', process.execPath, [
resolve(process.argv[1]),
])
}
} else {
app.setAsDefaultProtocolClient('jan')
}
const createMainWindow = () => {
const startUrl = app.isPackaged ? `file://${mainPath}` : mainUrl
windowManager.createMainWindow(preloadPath, startUrl)
}
// Generate a random token for the app
// This token is used for authentication when making request to cortex.cpp server
process.env.appToken = randomBytes(16).toString('hex')
app
.whenReady()
.then(() => {
if (!gotTheLock) {
app.quit()
throw new Error('Another instance of the app is already running')
} else {
app.on(
'second-instance',
(_event, commandLine, _workingDirectory): void => {
if (process.platform === 'win32' || process.platform === 'linux') {
// this is for handling deeplink on windows and linux
// since those OS will emit second-instance instead of open-url
const url = commandLine.pop()
if (url) {
windowManager.sendMainAppDeepLink(url)
}
}
windowManager.showMainWindow()
}
)
}
})
.then(setupCore)
.then(createUserSpace)
.then(registerLogger)
.then(migrate)
.then(setupExtensions)
.then(setupMenu)
.then(handleIPCs)
.then(() => process.env.CI !== 'e2e' && createQuickAskWindow())
.then(createMainWindow)
.then(handleAppUpdates)
.then(registerGlobalShortcuts)
.then(() => {
if (!app.isPackaged) {
setupReactDevTool()
windowManager.mainWindow?.webContents.openDevTools()
}
})
.then(() => process.env.CI !== 'e2e' && trayManager.createSystemTray())
.then(logSystemInfo)
.then(() => {
app.on('activate', () => {
if (!BrowserWindow.getAllWindows().length) {
createMainWindow()
} else {
windowManager.showMainWindow()
}
})
})
app.on('open-url', (_event, url) => {
windowManager.sendMainAppDeepLink(url)
})
app.on('before-quit', function (_event) {
trayManager.destroyCurrentTray()
})
app.once('quit', () => {
cleanUpAndQuit()
})
app.once('window-all-closed', () => {
// Feature Toggle for Quick Ask
if (
getAppConfigurations().quick_ask &&
!windowManager.isQuickAskWindowDestroyed()
)
return
cleanUpAndQuit()
})
function createQuickAskWindow() {
// Feature Toggle for Quick Ask
if (!getAppConfigurations().quick_ask) return
const startUrl = app.isPackaged ? `file://${quickAskPath}` : quickAskUrl
windowManager.createQuickAskWindow(preloadQuickAskPath, startUrl)
}
/**
* Handles various IPC messages from the renderer process.
*/
function handleIPCs() {
// Inject core handlers for IPCs
injectHandler()
// Handle native IPCs
handleAppIPCs()
}
/*
** Suppress Node error messages
*/
process.on('uncaughtException', function (err) {
log(`Error: ${err}`)
})

View File

@ -1,21 +0,0 @@
const DEFAULT_MIN_WIDTH = 400
const DEFAULT_MIN_HEIGHT = 600
export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = {
skipTaskbar: false,
minWidth: DEFAULT_MIN_WIDTH,
minHeight: DEFAULT_MIN_HEIGHT,
show: true,
// we want to go frameless on windows and linux
transparent: process.platform === 'darwin',
frame: process.platform === 'darwin',
titleBarStyle: 'hiddenInset',
vibrancy: 'fullscreen-ui',
visualEffectState: 'active',
backgroundMaterial: 'acrylic',
autoHideMenuBar: true,
trafficLightPosition: {
x: 16,
y: 10,
},
}

View File

@ -1,22 +0,0 @@
const DEFAULT_WIDTH = 556
const DEFAULT_HEIGHT = 60
export const quickAskWindowConfig: Electron.BrowserWindowConstructorOptions = {
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
skipTaskbar: true,
acceptFirstMouse: true,
hasShadow: true,
alwaysOnTop: true,
show: false,
fullscreenable: false,
resizable: false,
center: true,
movable: true,
maximizable: false,
focusable: true,
transparent: false,
frame: false,
type: 'panel',
}

View File

@ -1,51 +0,0 @@
import { join } from 'path'
import { Tray, app, Menu } from 'electron'
import { windowManager } from '../managers/window'
import { getAppConfigurations } from '@janhq/core/node'
class TrayManager {
currentTray: Tray | undefined
createSystemTray = () => {
// Feature Toggle for Quick Ask
if (!getAppConfigurations().quick_ask) return
if (this.currentTray) {
return
}
const iconPath = join(app.getAppPath(), 'icons', 'icon-tray.png')
const tray = new Tray(iconPath)
tray.setToolTip(app.getName())
tray.on('click', () => {
windowManager.showQuickAskWindow()
})
// Add context menu for windows only
if (process.platform === 'win32') {
const contextMenu = Menu.buildFromTemplate([
{
label: 'Open Jan',
type: 'normal',
click: () => windowManager.showMainWindow(),
},
{
label: 'Open Quick Ask',
type: 'normal',
click: () => windowManager.showQuickAskWindow(),
},
{ label: 'Quit', type: 'normal', click: () => app.quit() },
])
tray.setContextMenu(contextMenu)
}
this.currentTray = tray
}
destroyCurrentTray() {
this.currentTray?.destroy()
this.currentTray = undefined
}
}
export const trayManager = new TrayManager()

View File

@ -1,215 +0,0 @@
import { BrowserWindow, app, shell } from 'electron'
import { quickAskWindowConfig } from './quickAskWindowConfig'
import { mainWindowConfig } from './mainWindowConfig'
import { getAppConfigurations, AppEvent } from '@janhq/core/node'
import { getBounds, saveBounds } from '../utils/setup'
/**
* Manages the current window instance.
*/
// TODO: refactor this
let isAppQuitting = false
class WindowManager {
public mainWindow?: BrowserWindow
private _quickAskWindow: BrowserWindow | undefined = undefined
private _quickAskWindowVisible = false
private _mainWindowVisible = false
private deeplink: string | undefined
/**
* Creates a new window instance.
* @returns The created window instance.
*/
async createMainWindow(preloadPath: string, startUrl: string) {
const bounds = await getBounds()
this.mainWindow = new BrowserWindow({
...mainWindowConfig,
width: bounds.width,
height: bounds.height,
show: false,
x: bounds.x,
y: bounds.y,
webPreferences: {
nodeIntegration: true,
preload: preloadPath,
webSecurity: false,
},
})
if (process.platform === 'win32' || process.platform === 'linux') {
/// This is work around for windows deeplink.
/// second-instance event is not fired when app is not open, so the app
/// does not received the deeplink.
const commandLine = process.argv.slice(1)
if (commandLine.length > 0) {
const url = commandLine[0]
this.sendMainAppDeepLink(url)
}
}
this.mainWindow.on('resized', () => {
saveBounds(this.mainWindow?.getBounds())
})
this.mainWindow.on('moved', () => {
saveBounds(this.mainWindow?.getBounds())
})
/* Load frontend app to the window */
this.mainWindow.loadURL(startUrl)
/* Open external links in the default browser */
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
app.on('before-quit', function () {
isAppQuitting = true
})
windowManager.mainWindow?.on('close', function (evt) {
// Feature Toggle for Quick Ask
if (!getAppConfigurations().quick_ask) return
if (!isAppQuitting) {
evt.preventDefault()
windowManager.hideMainWindow()
}
})
windowManager.mainWindow?.on('ready-to-show', function () {
windowManager.mainWindow?.show()
})
}
createQuickAskWindow(preloadPath: string, startUrl: string): void {
this._quickAskWindow = new BrowserWindow({
...quickAskWindowConfig,
webPreferences: {
nodeIntegration: true,
preload: preloadPath,
webSecurity: false,
},
})
this._quickAskWindow.loadURL(startUrl)
this._quickAskWindow.on('blur', () => {
this.hideQuickAskWindow()
})
}
isMainWindowVisible(): boolean {
return this._mainWindowVisible
}
hideMainWindow(): void {
this.mainWindow?.hide()
this._mainWindowVisible = false
}
showMainWindow(): void {
this.mainWindow?.show()
this._mainWindowVisible = true
}
hideQuickAskWindow(): void {
this._quickAskWindow?.hide()
this._quickAskWindowVisible = false
}
showQuickAskWindow(): void {
this._quickAskWindow?.show()
this._quickAskWindowVisible = true
}
closeQuickAskWindow(): void {
if (this._quickAskWindow?.isDestroyed()) return
this._quickAskWindow?.close()
this._quickAskWindow?.destroy()
this._quickAskWindow = undefined
this._quickAskWindowVisible = false
}
isQuickAskWindowVisible(): boolean {
return this._quickAskWindowVisible
}
isQuickAskWindowDestroyed(): boolean {
return this._quickAskWindow?.isDestroyed() ?? true
}
/**
* Expand the quick ask window
*/
expandQuickAskWindow(heightOffset: number): void {
const width = quickAskWindowConfig.width!
const height = quickAskWindowConfig.height! + heightOffset
this._quickAskWindow?.setMinimumSize(width, height)
this._quickAskWindow?.setSize(width, height, true)
}
/**
* Send the selected text to the quick ask window.
*/
sendQuickAskSelectedText(selectedText: string): void {
this._quickAskWindow?.webContents.send(
AppEvent.onSelectedText,
selectedText
)
}
/**
* Try to send the deep link to the main app.
*/
sendMainAppDeepLink(url: string): void {
this.deeplink = url
const interval = setInterval(() => {
if (!this.deeplink) clearInterval(interval)
const mainWindow = this.mainWindow
if (mainWindow) {
mainWindow.webContents.send(AppEvent.onDeepLink, this.deeplink)
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
}, 500)
}
/**
* Send main view state to the main app.
*/
sendMainViewState(route: string) {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(AppEvent.onMainViewStateChange, route)
}
}
/**
* Clean up all windows.
*/
cleanUp(): void {
if (!this.mainWindow?.isDestroyed()) {
this.mainWindow?.close()
this.mainWindow?.destroy()
this.mainWindow = undefined
this._mainWindowVisible = false
}
if (!this._quickAskWindow?.isDestroyed()) {
this._quickAskWindow?.close()
this._quickAskWindow?.destroy()
this._quickAskWindow = undefined
this._quickAskWindowVisible = false
}
}
/**
* Acknowledges that the window has received a deep link. We can remove it.
*/
ackDeepLink() {
this.deeplink = undefined
}
}
export const windowManager = new WindowManager()

View File

@ -1,29 +0,0 @@
const yaml = require('js-yaml')
const fs = require('fs')
// get two file paths from arguments:
const [, , ...args] = process.argv
const file1 = args[0]
const file2 = args[1]
const file3 = args[2]
// check that all arguments are present and throw error instead
if (!file1 || !file2 || !file3) {
throw new Error(
'Please provide 3 file paths as arguments: path to file1, to file2 and destination path'
)
}
const doc1 = yaml.load(fs.readFileSync(file1, 'utf8'))
console.log('doc1: ', doc1)
const doc2 = yaml.load(fs.readFileSync(file2, 'utf8'))
console.log('doc2: ', doc2)
const merged = { ...doc1, ...doc2 }
merged.files.push(...doc1.files)
console.log('merged', merged)
const mergedYml = yaml.dump(merged)
fs.writeFileSync(file3, mergedYml, 'utf8')

View File

@ -1,146 +0,0 @@
{
"name": "jan",
"version": "0.1.1740752217",
"main": "./build/main.js",
"author": "Jan <service@jan.ai>",
"license": "MIT",
"productName": "Jan",
"homepage": "https://github.com/menloresearch/jan/tree/main/electron",
"description": "Use offline LLMs with your own data. Run open source models like Llama2 or Falcon on your internal computers/servers.",
"build": {
"appId": "jan.ai.app",
"productName": "Jan",
"files": [
"renderer/**/*",
"build/**/*.{js,map}",
"pre-install",
"themes",
"scripts/**/*",
"icons/**/*",
"themes",
"shared"
],
"asarUnpack": [
"pre-install",
"themes",
"docs",
"scripts",
"icons",
"themes",
"shared"
],
"publish": [
{
"provider": "github",
"owner": "janhq",
"repo": "jan"
}
],
"extends": null,
"mac": {
"type": "distribution",
"entitlements": "./entitlements.mac.plist",
"entitlementsInherit": "./entitlements.mac.plist",
"notarize": {
"teamId": "F8AH6NHVY5"
},
"icon": "icons/icon.png"
},
"linux": {
"target": [
"deb"
],
"category": "Utility",
"icon": "icons/"
},
"win": {
"icon": "icons/icon.png",
"target": [
"nsis"
]
},
"nsis": {
"oneClick": true,
"installerIcon": "icons/icon.ico",
"uninstallerIcon": "icons/icon.ico",
"include": "scripts/uninstaller.nsh",
"deleteAppDataOnUninstall": true
},
"protocols": [
{
"name": "Jan",
"schemes": [
"jan"
]
}
],
"artifactName": "jan-${os}-${arch}-${version}.${ext}"
},
"scripts": {
"lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"",
"test:e2e": "DEBUG=pw:browser xvfb-maybe -- playwright test --workers=1",
"copy:assets": "rimraf --glob \"./pre-install/*.tgz\" && cpx \"../pre-install/*.tgz\" \"./pre-install\"",
"version-patch": "run-script-os",
"version-patch:darwin:linux": "jq '.version' package.json | tr -d '\"' > .version.bak && jq --arg ver \"0.1.$(date +%s)\" '.version = $ver' package.json > package.tmp && mv package.tmp package.json",
"version-patch:win32": "node -e \"const fs=require('fs');const pkg=require('./package.json');const bak=pkg.version;fs.writeFileSync('.version.bak',bak);pkg.version='0.1.'+Math.floor(Date.now()/1000);fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\"",
"version-restore": "run-script-os",
"version-restore:darwin:linux": "jq --arg ver $(cat .version.bak) '.version = $ver' package.json > package.tmp && mv package.tmp package.json && rm .version.bak",
"version-restore:win32": "node -e \"const fs=require('fs');const pkg=require('./package.json');const bak=fs.readFileSync('.version.bak','utf8');pkg.version=bak;fs.writeFileSync('package.json',JSON.stringify(pkg,null,2));\"",
"dev:darwin:linux": "yarn copy:assets && tsc -p . && yarn version-patch && electron . && yarn version-restore",
"dev:windows": "yarn copy:assets && tsc -p . && electron .",
"dev": "run-script-os",
"compile": "tsc -p .",
"start": "electron .",
"build": "yarn copy:assets && run-script-os",
"build:test": "yarn copy:assets && run-script-os",
"build:test:darwin": "tsc -p . && electron-builder -p never -m --dir",
"build:test:win32": "tsc -p . && electron-builder -p never -w --dir",
"build:test:linux": "tsc -p . && electron-builder -p never -l --dir",
"build:darwin": "tsc -p . && electron-builder -p never -m --universal",
"build:win32": "tsc -p . && electron-builder -p never -w",
"build:linux": "tsc -p . && electron-builder -p never -l deb -l AppImage",
"build:publish": "yarn copy:assets && run-script-os",
"build:publish:darwin": "tsc -p . && electron-builder -p always -m --universal",
"build:publish:win32": "tsc -p . && electron-builder -p always -w",
"build:publish:linux": "tsc -p . && electron-builder -p always -l deb -l AppImage"
},
"dependencies": {
"@alumna/reflect": "^1.1.3",
"@janhq/core": "link:../core",
"@janhq/server": "link:../server",
"@kirillvakalov/nut-tree__nut-js": "4.2.1-2",
"@npmcli/arborist": "^7.1.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.1.7",
"fs-extra": "^11.2.0",
"pacote": "^21.0.0",
"request": "^2.88.2",
"request-progress": "^3.0.0",
"ulidx": "^2.3.0"
},
"devDependencies": {
"@electron/notarize": "^2.5.0",
"@playwright/test": "^1.38.1",
"@reportportal/agent-js-playwright": "^5.1.7",
"@types/npmcli__arborist": "^5.6.4",
"@types/pacote": "^11.1.7",
"@types/request": "^2.48.12",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"electron": "30.0.6",
"electron-builder": "^24.13.3",
"electron-builder-squirrel-windows": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-playwright-helpers": "^1.6.0",
"eslint": "8.57.0",
"eslint-plugin-react": "^7.34.0",
"rimraf": "^5.0.5",
"run-script-os": "^1.1.6",
"typescript": "^5.3.3",
"xvfb-maybe": "^0.2.1"
},
"installConfig": {
"hoistingLimits": "workspaces"
},
"packageManager": "yarn@4.5.3"
}

View File

@ -1,14 +0,0 @@
import { PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
testDir: './tests/e2e',
retries: 0,
globalTimeout: 350000,
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
// reporter: [['html', { outputFolder: './playwright-report' }]],
}
export default config

View File

@ -1,32 +0,0 @@
/**
* Exposes a set of APIs to the renderer process via the contextBridge object.
* @module preload
*/
import { APIEvents, APIRoutes } from '@janhq/core/node'
import { contextBridge, ipcRenderer } from 'electron'
const interfaces: { [key: string]: (...args: any[]) => any } = {}
// Loop over each route in APIRoutes
APIRoutes.forEach((method) => {
// For each method, create a function on the interfaces object
// This function invokes the method on the ipcRenderer with any provided arguments
interfaces[method] = (...args: any[]) => ipcRenderer.invoke(method, ...args)
})
// Loop over each method in APIEvents
APIEvents.forEach((method) => {
// For each method, create a function on the interfaces object
// This function sets up an event listener on the ipcRenderer for the method
// The handler for the event is provided as an argument to the function
interfaces[method] = (handler: any) => ipcRenderer.on(method, handler)
})
// Expose the 'interfaces' object in the main world under the name 'electronAPI'
// This allows the renderer process to access these methods directly
contextBridge.exposeInMainWorld('electronAPI', {
...interfaces,
isQuickAsk: () => true,
})

View File

@ -1,60 +0,0 @@
/**
* Exposes a set of APIs to the renderer process via the contextBridge object.
* @module preload
*/
import { APIEvents, APIRoutes, AppConfiguration } from '@janhq/core/node'
import { contextBridge, ipcRenderer } from 'electron'
import { readdirSync } from 'fs'
const interfaces: { [key: string]: (...args: any[]) => any } = {}
// Loop over each route in APIRoutes
APIRoutes.forEach((method) => {
// For each method, create a function on the interfaces object
// This function invokes the method on the ipcRenderer with any provided arguments
interfaces[method] = (...args: any[]) => ipcRenderer.invoke(method, ...args)
})
// Loop over each method in APIEvents
APIEvents.forEach((method) => {
// For each method, create a function on the interfaces object
// This function sets up an event listener on the ipcRenderer for the method
// The handler for the event is provided as an argument to the function
interfaces[method] = (handler: any) => ipcRenderer.on(method, handler)
})
interfaces['changeDataFolder'] = async (path) => {
const appConfiguration: AppConfiguration = await ipcRenderer.invoke(
'getAppConfigurations'
)
const currentJanDataFolder = appConfiguration.data_folder
appConfiguration.data_folder = path
const reflect = require('@alumna/reflect')
const { err } = await reflect({
src: currentJanDataFolder,
dest: path,
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
if (err) {
console.error(err)
throw err
}
await ipcRenderer.invoke('updateAppConfiguration', appConfiguration)
}
interfaces['isDirectoryEmpty'] = async (path) => {
const dirChildren = await readdirSync(path)
return dirChildren.filter((x) => x !== '.DS_Store').length === 0
}
// Expose the 'interfaces' object in the main world under the name 'electronAPI'
// This allows the renderer process to access these methods directly
contextBridge.exposeInMainWorld('electronAPI', {
...interfaces,
isQuickAsk: () => false,
})

View File

@ -1,46 +0,0 @@
!include nsDialogs.nsh
XPStyle on
!macro customUnInstall
${ifNot} ${isUpdated}
; Define the process name of your Electron app
StrCpy $0 "Jan.exe"
; Check if the application is running
nsExec::ExecToStack 'tasklist /FI "IMAGENAME eq $0" /NH'
Pop $1
StrCmp $1 "" notRunning
; If the app is running, notify the user and attempt to close it
MessageBox MB_OK "Jan is being uninstalled, force close app." IDOK forceClose
forceClose:
; Attempt to kill the running application
nsExec::ExecToStack 'taskkill /F /IM $0'
Pop $1
; Proceed with uninstallation
Goto continueUninstall
notRunning:
; If the app is not running, proceed with uninstallation
Goto continueUninstall
continueUninstall:
; Proceed with uninstallation
DeleteRegKey HKLM "Software\Jan"
RMDir /r "$INSTDIR"
Delete "$INSTDIR\*.*"
; Clean up shortcuts and app data
Delete "$DESKTOP\Jan.lnk"
Delete "$STARTMENU\Programs\Jan.lnk"
RMDir /r "$APPDATA\Jan"
RMDir /r "$LOCALAPPDATA\jan-updater"
; Close the uninstaller
Quit
${endIf}
!macroend

View File

@ -1,69 +0,0 @@
const { exec } = require('child_process')
function execCommandWithRetry(command, retries = 3) {
return new Promise((resolve, reject) => {
const execute = (attempt) => {
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`Error: ${error}`)
if (attempt < retries) {
console.log(`Retrying... Attempt ${attempt + 1}`)
execute(attempt + 1)
} else {
return reject(error)
}
} else {
console.log(`stdout: ${stdout}`)
console.error(`stderr: ${stderr}`)
resolve()
}
})
}
execute(0)
})
}
function sign({
path,
name,
certUrl,
clientId,
tenantId,
clientSecret,
certName,
timestampServer,
version,
}) {
return new Promise((resolve, reject) => {
const command = `azuresigntool.exe sign -kvu "${certUrl}" -kvi "${clientId}" -kvt "${tenantId}" -kvs "${clientSecret}" -kvc "${certName}" -tr "${timestampServer}" -v "${path}"`
execCommandWithRetry(command)
.then(resolve)
.catch(reject)
})
}
exports.default = async function (options) {
const certUrl = process.env.AZURE_KEY_VAULT_URI
const clientId = process.env.AZURE_CLIENT_ID
const tenantId = process.env.AZURE_TENANT_ID
const clientSecret = process.env.AZURE_CLIENT_SECRET
const certName = process.env.AZURE_CERT_NAME
const timestampServer = 'http://timestamp.globalsign.com/tsa/r6advanced1'
try {
await sign({
path: options.path,
name: 'jan-win-x64',
certUrl,
clientId,
tenantId,
clientSecret,
certName,
timestampServer,
version: options.version,
})
} catch (error) {
console.error('Failed to sign after 3 attempts:', error)
process.exit(1)
}
}

View File

@ -1,10 +0,0 @@
const jestRunner = require('jest-runner');
class EmptyTestFileRunner extends jestRunner.default {
async runTests(tests, watcher, onStart, onResult, onFailure, options) {
const nonEmptyTests = tests.filter(test => test.context.hasteFS.getSize(test.path) > 0);
return super.runTests(nonEmptyTests, watcher, onStart, onResult, onFailure, options);
}
}
module.exports = EmptyTestFileRunner;

View File

@ -1,4 +0,0 @@
export const Constants = {
VIDEO_DIR: './playwright-video',
TIMEOUT: '300000',
}

View File

@ -1,126 +0,0 @@
import {
_electron as electron,
BrowserContext,
ElectronApplication,
expect,
Page,
test as base,
} from '@playwright/test'
import {
ElectronAppInfo,
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
import { Constants } from './constants'
import { HubPage } from '../pages/hubPage'
import { CommonActions } from '../pages/commonActions'
import { rmSync } from 'fs'
import * as path from 'path'
export let electronApp: ElectronApplication
export let page: Page
export let appInfo: ElectronAppInfo
export const TIMEOUT = parseInt(process.env.TEST_TIMEOUT || Constants.TIMEOUT)
export async function setupElectron() {
console.log(`TEST TIMEOUT: ${TIMEOUT}`)
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main, '--no-sandbox'], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
// recordVideo: { dir: Constants.VIDEO_DIR }, // Specify the directory for video recordings
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow({
timeout: TIMEOUT,
})
}
export async function teardownElectron() {
await page.close()
await electronApp.close()
}
/**
* this fixture is needed to record and attach videos / screenshot on failed tests when
* tests are run in serial mode (i.e. browser is not closed between tests)
*/
export const test = base.extend<
{
commonActions: CommonActions
hubPage: HubPage
attachVideoPage: Page
attachScreenshotsToReport: void
},
{ createVideoContext: BrowserContext }
>({
commonActions: async ({ request }, use, testInfo) => {
await use(new CommonActions(page, testInfo))
},
hubPage: async ({ commonActions }, use) => {
await use(new HubPage(page, commonActions))
},
createVideoContext: [
async ({ playwright }, use) => {
const context = electronApp.context()
await use(context)
},
{ scope: 'worker' },
],
attachVideoPage: [
async ({ createVideoContext }, use, testInfo) => {
await use(page)
if (testInfo.status !== testInfo.expectedStatus) {
const path = await createVideoContext.pages()[0].video()?.path()
await createVideoContext.close()
await testInfo.attach('video', {
path: path,
})
}
},
{ scope: 'test', auto: true },
],
attachScreenshotsToReport: [
async ({ commonActions }, use, testInfo) => {
await use()
// After the test, we can check whether the test passed or failed.
if (testInfo.status !== testInfo.expectedStatus) {
await commonActions.takeScreenshot('')
}
},
{ auto: true },
],
})
test.beforeAll(async () => {
rmSync(path.join(__dirname, '../../test-data'), {
recursive: true,
force: true,
})
test.setTimeout(TIMEOUT)
await setupElectron()
await page.waitForSelector('img[alt="Jan - Logo"]', {
state: 'visible',
timeout: TIMEOUT,
})
})
test.afterAll(async () => {
// teardownElectron()
})

View File

@ -1,25 +0,0 @@
import { test, appInfo, page, TIMEOUT } from '../config/fixtures'
import { expect } from '@playwright/test'
test.beforeAll(async () => {
expect(appInfo).toMatchObject({
asar: true,
executable: expect.anything(),
main: expect.anything(),
name: 'jan',
packageJson: expect.objectContaining({ name: 'jan' }),
platform: process.platform,
resourcesDir: expect.anything(),
})
})
test('explores hub', async ({ hubPage }) => {
await hubPage.navigateByMenu()
await hubPage.verifyContainerVisible()
await hubPage.scrollToBottom()
const useModelBtn = page.getByTestId(/^setup-btn/).first()
await expect(useModelBtn).toBeVisible({
timeout: TIMEOUT,
})
})

View File

@ -1,15 +0,0 @@
import { expect } from '@playwright/test'
import { page, test, TIMEOUT } from '../config/fixtures'
test('renders left navigation panel', async () => {
const threadBtn = page.getByTestId('Thread').first()
await expect(threadBtn).toBeVisible({ timeout: TIMEOUT })
// Chat section should be there
await page.getByTestId('Local API Server').first().click({
timeout: TIMEOUT,
})
const localServer = page.getByTestId('local-server-testid').first()
await expect(localServer).toBeVisible({
timeout: TIMEOUT,
})
})

View File

@ -1,11 +0,0 @@
import { expect } from '@playwright/test'
import { test, page, TIMEOUT } from '../config/fixtures'
test('shows settings', async () => {
await page.getByTestId('Settings').first().click({
timeout: TIMEOUT,
})
const settingDescription = page.getByTestId('testid-setting-description')
await expect(settingDescription).toBeVisible({ timeout: TIMEOUT })
})

View File

@ -1,18 +0,0 @@
import { expect } from '@playwright/test'
import { page, test, TIMEOUT } from '../config/fixtures'
test('show onboarding screen without any threads created or models downloaded', async () => {
await page.getByTestId('Thread').first().click({
timeout: TIMEOUT,
})
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
if ((await denyButton.count()) > 0) {
await denyButton.click({ force: true })
}
const onboardScreen = page.getByTestId('onboard-screen')
await expect(onboardScreen).toBeVisible({
timeout: TIMEOUT,
})
})

View File

@ -1,59 +0,0 @@
import { Page, expect } from '@playwright/test'
import { CommonActions } from './commonActions'
import { TIMEOUT } from '../config/fixtures'
export class BasePage {
menuId: string
constructor(
protected readonly page: Page,
readonly action: CommonActions,
protected containerId: string
) {}
public getValue(key: string) {
return this.action.getValue(key)
}
public setValue(key: string, value: string) {
this.action.setValue(key, value)
}
async takeScreenshot(name: string = '') {
await this.action.takeScreenshot(name)
}
async navigateByMenu() {
await this.clickFirstElement(this.menuId)
}
async clickFirstElement(testId: string) {
await this.page.getByTestId(testId).first().click()
}
async verifyContainerVisible() {
const container = this.page.getByTestId(this.containerId)
expect(container.isVisible()).toBeTruthy()
}
async scrollToBottom() {
await this.page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight)
})
}
async waitUpdateLoader() {
await this.isElementVisible('img[alt="Jan - Logo"]')
}
//wait and find a specific element with its selector and return Visible
async isElementVisible(selector: any) {
let isVisible = true
await this.page
.waitForSelector(selector, { state: 'visible', timeout: TIMEOUT })
.catch(() => {
isVisible = false
})
return isVisible
}
}

View File

@ -1,34 +0,0 @@
import { Page, TestInfo } from '@playwright/test'
import { page } from '../config/fixtures'
export class CommonActions {
private testData = new Map<string, string>()
constructor(
public page: Page,
public testInfo: TestInfo
) {}
async takeScreenshot(name: string) {
const screenshot = await page.screenshot({
fullPage: true,
})
const attachmentName = `${this.testInfo.title}_${name || new Date().toISOString().slice(5, 19).replace(/[-:]/g, '').replace('T', '_')}`
await this.testInfo.attach(attachmentName.replace(/\s+/g, ''), {
body: screenshot,
contentType: 'image/png',
})
}
async hooks() {
console.log('hook from the scenario page')
}
setValue(key: string, value: string) {
this.testData.set(key, value)
}
getValue(key: string) {
return this.testData.get(key)
}
}

View File

@ -1,15 +0,0 @@
import { Page } from '@playwright/test'
import { BasePage } from './basePage'
import { CommonActions } from './commonActions'
export class HubPage extends BasePage {
readonly menuId: string = 'Hub'
static readonly containerId: string = 'hub-container-test-id'
constructor(
public page: Page,
readonly action: CommonActions
) {
super(page, action, HubPage.containerId)
}
}

View File

@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"noImplicitAny": true,
"sourceMap": true,
"strict": true,
"outDir": "./build",
"rootDir": "./",
"noEmitOnError": true,
"esModuleInterop": true,
"baseUrl": ".",
"allowJs": true,
"skipLibCheck": true,
"paths": { "*": ["node_modules/*"] },
"typeRoots": ["node_modules/@types"]
},
"ts-node": {
"esm": true
},
"include": ["./**/*.ts"],
"exclude": ["core", "build", "dist", "tests", "node_modules", "test-data"]
}

View File

@ -1,14 +0,0 @@
import { ModuleManager } from '@janhq/core/node'
import { windowManager } from './../managers/window'
import { dispose } from './disposable'
import { app } from 'electron'
export function cleanUpAndQuit() {
if (!ModuleManager.instance.cleaningResource) {
ModuleManager.instance.cleaningResource = true
windowManager.cleanUp()
dispose(ModuleManager.instance.requiredModules)
ModuleManager.instance.clearImportedModules()
app.quit()
}
}

View File

@ -1,13 +0,0 @@
export const setupReactDevTool = async () => {
// Which means you're running from source code
const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import(
'electron-devtools-installer'
) // Don't use import on top level, since the installer package is dev-only
try {
const name = await installExtension(REACT_DEVELOPER_TOOLS)
console.debug(`Added Extension: ${name}`)
} catch (err) {
console.error('An error occurred while installing devtools:', err)
// Only log the error and don't throw it because it's not critical
}
}

View File

@ -1,8 +0,0 @@
export function dispose(requiredModules: Record<string, any>) {
for (const key in requiredModules) {
const module = requiredModules[key]
if (typeof module['dispose'] === 'function') {
module['dispose']()
}
}
}

View File

@ -1,12 +0,0 @@
import { getJanExtensionsPath, init } from '@janhq/core/node'
export const setupExtensions = async () => {
init({
// Function to check from the main process that user wants to install a extension
confirmInstall: async (_extensions: string[]) => {
return true
},
// Path to install extension to
extensionsPath: getJanExtensionsPath(),
})
}

View File

@ -1,167 +0,0 @@
import {
createWriteStream,
existsSync,
mkdirSync,
readdir,
stat,
unlink,
writeFileSync,
} from 'fs'
import util from 'util'
import {
getAppConfigurations,
getJanDataFolderPath,
Logger,
LoggerManager,
} from '@janhq/core/node'
import path, { join } from 'path'
/**
* File Logger
*/
export class FileLogger implements Logger {
name = 'file'
logCleaningInterval: number = 120000
timeout: NodeJS.Timeout | undefined
appLogPath: string = './'
logEnabled: boolean = true
constructor(
logEnabled: boolean = true,
logCleaningInterval: number = 120000
) {
this.logEnabled = logEnabled
if (logCleaningInterval) this.logCleaningInterval = logCleaningInterval
const appConfigurations = getAppConfigurations()
const logFolderPath = join(appConfigurations.data_folder, 'logs')
if (!existsSync(logFolderPath)) {
mkdirSync(logFolderPath, { recursive: true })
}
this.appLogPath = join(logFolderPath, 'app.log')
}
log(args: any) {
if (!this.logEnabled) return
let message = args[0]
const scope = args[1]
if (!message) return
const path = this.appLogPath
if (!scope && !message.startsWith('[')) {
message = `[APP]::${message}`
} else if (scope) {
message = `${scope}::${message}`
}
message = `${new Date().toISOString()} ${message}`
writeLog(message, path)
}
cleanLogs(
maxFileSizeBytes?: number | undefined,
daysToKeep?: number | undefined
): void {
// clear existing timeout
// in case we rerun it with different values
if (this.timeout) clearTimeout(this.timeout)
this.timeout = undefined
if (!this.logEnabled) return
console.log(
'Validating app logs. Next attempt in ',
this.logCleaningInterval
)
const size = maxFileSizeBytes ?? 1 * 1024 * 1024 // 1 MB
const days = daysToKeep ?? 7 // 7 days
const logDirectory = path.join(getJanDataFolderPath(), 'logs')
// Perform log cleaning
const currentDate = new Date()
if (existsSync(logDirectory))
readdir(logDirectory, (err, files) => {
if (err) {
console.error('Error reading log directory:', err)
return
}
files.forEach((file) => {
const filePath = path.join(logDirectory, file)
stat(filePath, (err, stats) => {
if (err) {
console.error('Error getting file stats:', err)
return
}
// Check size
if (stats.size > size) {
unlink(filePath, (err) => {
if (err) {
console.error('Error deleting log file:', err)
return
}
console.debug(
`Deleted log file due to exceeding size limit: ${filePath}`
)
})
} else {
// Check age
const creationDate = new Date(stats.ctime)
const daysDifference = Math.floor(
(currentDate.getTime() - creationDate.getTime()) /
(1000 * 3600 * 24)
)
if (daysDifference > days) {
unlink(filePath, (err) => {
if (err) {
console.error('Error deleting log file:', err)
return
}
console.debug(`Deleted old log file: ${filePath}`)
})
}
}
})
})
})
// Schedule the next execution with doubled delays
this.timeout = setTimeout(
() => this.cleanLogs(maxFileSizeBytes, daysToKeep),
this.logCleaningInterval
)
}
}
/**
* Write log function implementation
* @param message
* @param logPath
*/
const writeLog = (message: string, logPath: string) => {
if (!existsSync(logPath)) {
const logDirectory = path.join(getJanDataFolderPath(), 'logs')
if (!existsSync(logDirectory)) {
mkdirSync(logDirectory)
}
writeFileSync(logPath, message)
} else {
const logFile = createWriteStream(logPath, {
flags: 'a',
})
logFile.write(util.format(message) + '\n')
logFile.close()
console.debug(message)
}
}
/**
* Register logger for global application logging
*/
export const registerLogger = () => {
const logger = new FileLogger()
LoggerManager.instance().register(logger)
logger.cleanLogs()
}

View File

@ -1,129 +0,0 @@
// @ts-nocheck
import { app, Menu, shell, dialog } from 'electron'
import { autoUpdater } from 'electron-updater'
import { log } from '@janhq/core/node'
const isMac = process.platform === 'darwin'
import { windowManager } from '../managers/window'
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
{
label: app.name,
submenu: [
{
label: `About ${app.name}`,
click: () =>
dialog.showMessageBox({
title: `Jan`,
message: `Jan Version v${app.getVersion()}\n\nCopyright © 2024 Jan`,
}),
},
{
label: 'Check for Updates...',
click: () =>
// Check for updates and notify user if there are any
autoUpdater
.checkForUpdatesAndNotify()
.then((updateCheckResult) => {
if (
!updateCheckResult?.updateInfo ||
updateCheckResult?.updateInfo.version === app.getVersion()
) {
windowManager.mainWindow?.webContents.send(
AppEvent.onAppUpdateNotAvailable,
{}
)
return
}
})
.catch((error) => {
log('Error checking for updates:' + JSON.stringify(error))
}),
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{
label: `Settings`,
accelerator: 'CmdOrCtrl+,',
click: () => {
windowManager.showMainWindow()
windowManager.sendMainViewState('Settings')
},
},
{ type: 'separator' },
{ role: 'quit' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' },
]
: [{ role: 'close' }]),
],
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
await shell.openExternal('https://jan.ai/guides/')
},
},
],
},
]
export const menu = Menu.buildFromTemplate(template)
export const setupMenu = () => {
Menu.setApplicationMenu(menu)
}

View File

@ -1,77 +0,0 @@
import { app } from 'electron'
import { join } from 'path'
import {
rmdirSync,
existsSync,
mkdirSync,
readdirSync,
cpSync,
lstatSync,
} from 'fs'
import Store from 'electron-store'
import {
getJanDataFolderPath,
appResourcePath,
getJanExtensionsPath,
} from '@janhq/core/node'
/**
* Migrates the extensions & themes.
* If the `migrated_version` key in the `Store` object does not match the current app version,
* the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version.
* @returns A Promise that resolves when the migration is complete.
*/
export async function migrate() {
const store = new Store()
if (store.get('migrated_version') !== app.getVersion()) {
console.debug('start migration:', store.get('migrated_version'))
if (existsSync(getJanExtensionsPath()))
rmdirSync(getJanExtensionsPath(), { recursive: true })
await migrateThemes()
store.set('migrated_version', app.getVersion())
console.debug('migrate extensions done')
} else if (!existsSync(join(getJanDataFolderPath(), 'themes'))) {
await migrateThemes()
}
}
async function migrateThemes() {
if (!existsSync(join(getJanDataFolderPath(), 'themes')))
mkdirSync(join(getJanDataFolderPath(), 'themes'), { recursive: true })
const themes = readdirSync(join(appResourcePath(), 'themes'))
for (const theme of themes) {
const themePath = join(appResourcePath(), 'themes', theme)
await checkAndMigrateTheme(theme, themePath)
}
}
async function checkAndMigrateTheme(
sourceThemeName: string,
sourceThemePath: string
) {
const janDataThemesFolder = join(getJanDataFolderPath(), 'themes')
const existingTheme = readdirSync(janDataThemesFolder).find(
(theme) => theme === sourceThemeName
)
if (existingTheme) {
const desTheme = join(janDataThemesFolder, existingTheme)
if (!lstatSync(desTheme).isDirectory()) {
return
}
console.debug('Updating theme', existingTheme)
rmdirSync(desTheme, { recursive: true })
cpSync(sourceThemePath, join(janDataThemesFolder, sourceThemeName), {
recursive: true,
})
} else {
console.debug('Adding new theme', sourceThemeName)
cpSync(sourceThemePath, join(janDataThemesFolder, sourceThemeName), {
recursive: true,
})
}
}

View File

@ -1,16 +0,0 @@
import { mkdir } from 'fs-extra'
import { existsSync } from 'fs'
import { getJanDataFolderPath } from '@janhq/core/node'
export async function createUserSpace(): Promise<void> {
const janDataFolderPath = getJanDataFolderPath()
if (!existsSync(janDataFolderPath)) {
try {
await mkdir(janDataFolderPath)
} catch (err) {
console.error(
`Unable to create Jan data folder at ${janDataFolderPath}: ${err}`
)
}
}
}

View File

@ -1,44 +0,0 @@
import { clipboard, globalShortcut } from 'electron'
import { keyboard, Key } from "@kirillvakalov/nut-tree__nut-js"
/**
* Gets selected text by synthesizing the keyboard shortcut
* "CommandOrControl+c" then reading text from the clipboard
*/
export const getSelectedText = async () => {
const currentClipboardContent = clipboard.readText() // preserve clipboard content
clipboard.clear()
const hotkeys: Key[] = [
process.platform === 'darwin' ? Key.LeftCmd : Key.LeftControl,
Key.C,
]
await keyboard.pressKey(...hotkeys)
await keyboard.releaseKey(...hotkeys)
await new Promise((resolve) => setTimeout(resolve, 200)) // add a delay before checking clipboard
const selectedText = clipboard.readText()
clipboard.writeText(currentClipboardContent)
return selectedText
}
/**
* Registers a global shortcut of `accelerator`. The `callback` is called
* with the selected text when the registered shortcut is pressed by the user
*
* Returns `true` if the shortcut was registered successfully
*/
export const registerShortcut = (
accelerator: Electron.Accelerator,
callback: (selectedText: string) => void
) => {
return globalShortcut.register(accelerator, async () => {
callback(await getSelectedText())
})
}
/**
* Unregisters a global shortcut of `accelerator` and
* is equivalent to electron.globalShortcut.unregister
*/
export const unregisterShortcut = (accelerator: Electron.Accelerator) => {
globalShortcut.unregister(accelerator)
}

View File

@ -1,65 +0,0 @@
import { app, screen } from 'electron'
import Store from 'electron-store'
const DEFAULT_WIDTH = 1000
const DEFAULT_HEIGHT = 800
const storage = new Store()
export const setupCore = async () => {
// Setup core api for main process
global.core = {
// Define appPath function for app to retrieve app path globally
appPath: () => app.getPath('userData'),
}
}
export const getBounds = async () => {
const defaultBounds = {
x: undefined,
y: undefined,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
}
const bounds = (await storage.get('windowBounds')) as
| Electron.Rectangle
| undefined
// If no bounds are saved, use the defaults
if (!bounds) {
storage.set('windowBounds', defaultBounds)
return defaultBounds
}
// Validate that the bounds are on a valid display
const displays = screen.getAllDisplays()
const isValid = displays.some((display) => {
const { x, y, width, height } = display.bounds
return (
bounds.x >= x &&
bounds.x < x + width &&
bounds.y >= y &&
bounds.y < y + height
)
})
// If the position is valid, return the saved bounds, otherwise return default bounds
if (isValid) {
return bounds
} else {
const primaryDisplay = screen.getPrimaryDisplay()
const resetBounds = {
x: primaryDisplay.bounds.x,
y: primaryDisplay.bounds.y,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
}
storage.set('windowBounds', resetBounds)
return resetBounds
}
}
export const saveBounds = (bounds: Electron.Rectangle | undefined) => {
storage.set('windowBounds', bounds)
}

View File

@ -1,24 +0,0 @@
import { getAppConfigurations } from '@janhq/core/node'
import { registerShortcut } from './selectedText'
import { windowManager } from '../managers/window'
// TODO: Retrieve from config later
const quickAskHotKey = 'CommandOrControl+J'
export function registerGlobalShortcuts() {
if (!getAppConfigurations().quick_ask) return
const ret = registerShortcut(quickAskHotKey, (selectedText: string) => {
// Feature Toggle for Quick Ask
if (!windowManager.isQuickAskWindowVisible()) {
windowManager.showQuickAskWindow()
windowManager.sendQuickAskSelectedText(selectedText)
} else {
windowManager.hideQuickAskWindow()
}
})
if (!ret) {
console.error('Global shortcut registration failed')
} else {
console.log('Global shortcut registered successfully')
}
}

View File

@ -1,16 +0,0 @@
import { log } from '@janhq/core/node'
import { app } from 'electron'
import os from 'os'
export const logSystemInfo = (): void => {
log(`[SPECS]::Version: ${app.getVersion()}`)
log(`[SPECS]::CPUs: ${JSON.stringify(os.cpus())}`)
log(`[SPECS]::Machine: ${os.machine()}`)
log(`[SPECS]::Endianness: ${os.endianness()}`)
log(`[SPECS]::Parallelism: ${os.availableParallelism()}`)
log(`[SPECS]::Free Mem: ${os.freemem()}`)
log(`[SPECS]::Total Mem: ${os.totalmem()}`)
log(`[SPECS]::OS Version: ${os.version()}`)
log(`[SPECS]::OS Platform: ${os.platform()}`)
log(`[SPECS]::OS Release: ${os.release()}`)
}

View File

@ -2,7 +2,7 @@
"name": "@janhq/model-extension",
"productName": "Model Management",
"version": "1.0.36",
"description": "Handles model list, and settings.",
"description": "Manages model operations including listing, importing, updating, and deleting.",
"main": "dist/index.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",

View File

@ -1,6 +0,0 @@
.next/
node_modules/
dist/
*.hbs
*.mdx
*.mjs

View File

@ -1,8 +0,0 @@
{
"semi": false,
"singleQuote": true,
"quoteProps": "consistent",
"trailingComma": "es5",
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@ -1,13 +0,0 @@
# @janhq/joi
To install dependencies:
```bash
yarn install
```
To run:
```bash
yarn run dev
```

View File

@ -1,9 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.*'],
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
}

View File

View File

@ -1,78 +0,0 @@
{
"name": "@janhq/joi",
"version": "0.0.0",
"main": "dist/esm/index.js",
"types": "dist/index.d.ts",
"description": "A collection of UI component",
"files": [
"dist"
],
"keywords": [
"design-system"
],
"license": "MIT",
"homepage": "https://github.com/codecentrum/piksel#readme",
"repository": {
"type": "git",
"url": "https://github.com/codecentrum/piksel.git"
},
"bugs": "https://github.com/codecentrum/piksel/issues",
"scripts": {
"dev": "rollup -c -w",
"build": "rimraf ./dist || true && rollup -c",
"test": "jest"
},
"peerDependencies": {
"class-variance-authority": "^0.7.0",
"react": "^18",
"typescript": "^5.0.0"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@types/jest": "^29.5.12",
"autoprefixer": "10.4.16",
"jest": "^29.7.0",
"tailwind-merge": "^2.2.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-terser": "0.4.4",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/react": "^18.3.12",
"@types/react-dom": "^19",
"class-variance-authority": "^0.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-transform-css": "^6.0.1",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rimraf": "^6.0.1",
"rollup": "4.12.0",
"rollup-plugin-bundle-size": "1.0.3",
"rollup-plugin-commonjs": "10.1.0",
"rollup-plugin-copy": "3.5.0",
"rollup-plugin-dts": "6.1.0",
"rollup-plugin-peer-deps-external": "2.2.4",
"rollup-plugin-postcss": "4.0.2",
"rollup-plugin-typescript2": "0.36.0",
"sass": "^1.83.1",
"typescript": "^5.7.2"
},
"packageManager": "yarn@4.5.3"
}

View File

@ -1,79 +0,0 @@
import { readFileSync } from 'fs'
import dts from 'rollup-plugin-dts'
import terser from '@rollup/plugin-terser'
import autoprefixer from 'autoprefixer'
import commonjs from 'rollup-plugin-commonjs'
import bundleSize from 'rollup-plugin-bundle-size'
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
import postcss from 'rollup-plugin-postcss'
import typescript from 'rollup-plugin-typescript2'
import tailwindcss from 'tailwindcss'
import typescriptEngine from 'typescript'
import resolve from '@rollup/plugin-node-resolve'
import copy from 'rollup-plugin-copy'
const packageJson = JSON.parse(readFileSync('./package.json'))
import tailwindConfig from './tailwind.config.js'
export default [
{
input: `./src/index.ts`,
output: [
{
file: packageJson.main,
format: 'es',
exports: 'named',
sourcemap: false,
},
],
external: ['react', 'typescript', 'class-variance-authority'],
plugins: [
postcss({
plugins: [autoprefixer(), tailwindcss(tailwindConfig)],
sourceMap: true,
use: {
sass: {
silenceDeprecations: ['legacy-js-api'],
api: 'modern',
},
},
minimize: true,
extract: 'main.css',
}),
peerDepsExternal({ includeDependencies: true }),
commonjs(),
resolve(),
typescript({
tsconfig: './tsconfig.json',
typescript: typescriptEngine,
sourceMap: false,
exclude: [
'docs',
'dist',
'node_modules/**',
'**/*.test.ts',
'**/*.test.tsx',
],
}),
terser(),
],
watch: {
clearScreen: false,
},
},
{
input: 'dist/esm/index.d.ts',
output: [{ file: 'dist/index.d.ts', format: 'esm' }],
external: [/\.(sc|sa|c)ss$/],
plugins: [
dts(),
peerDepsExternal({ includeDependencies: true }),
copy({
targets: [{ src: 'dist/esm/main.css', dest: 'dist' }],
}),
bundleSize(),
],
},
]

View File

@ -1,64 +0,0 @@
import React from 'react'
import '@testing-library/jest-dom'
import { render, screen, fireEvent } from '@testing-library/react'
import { Accordion, AccordionItem } from './index'
// Mock the SCSS import
jest.mock('./styles.scss', () => ({}))
describe('Accordion', () => {
it('renders accordion with items', () => {
render(
<Accordion defaultValue={['item1']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
it('expands and collapses accordion items', () => {
render(
<Accordion defaultValue={[]}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
</Accordion>
)
const trigger = screen.getByText('Item 1')
// Initially, content should not be visible
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
// Click to expand
fireEvent.click(trigger)
expect(screen.getByText('Content 1')).toBeInTheDocument()
// Click to collapse
fireEvent.click(trigger)
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
})
it('respects defaultValue prop', () => {
render(
<Accordion defaultValue={['item2']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
})

View File

@ -1,45 +0,0 @@
import React, { ReactNode } from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDownIcon } from '@radix-ui/react-icons'
import './styles.scss'
type AccordionProps = {
defaultValue: string[]
children: ReactNode
}
type AccordionItemProps = {
children: ReactNode
value: string
title: string
}
const AccordionItem = ({ children, value, title }: AccordionItemProps) => {
return (
<AccordionPrimitive.Item className="accordion__item" value={value}>
<AccordionPrimitive.Header className="accordion__header">
<AccordionPrimitive.Trigger className="accordion__trigger">
<h6>{title}</h6>
<ChevronDownIcon className="accordion__chevron" aria-hidden />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
<AccordionPrimitive.Content className="accordion__content">
<div className="accordion__content--wrapper">{children}</div>
</AccordionPrimitive.Content>
</AccordionPrimitive.Item>
)
}
const Accordion = ({ defaultValue, children }: AccordionProps) => (
<AccordionPrimitive.Root
className="accordion"
type="multiple"
defaultValue={defaultValue}
>
{children}
</AccordionPrimitive.Root>
)
export { Accordion, AccordionItem }

View File

@ -1,73 +0,0 @@
.accordion {
border-top: 1px solid hsla(var(--app-border));
&__item {
overflow: hidden;
margin-top: 1px;
border-bottom: 1px solid hsla(var(--app-border));
:focus-within {
position: relative;
z-index: 1;
}
}
&__header {
display: flex;
}
&__trigger {
font-family: inherit;
background-color: transparent;
padding: 0 16px;
height: 40px;
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
}
&__content {
overflow: hidden;
&--wrapper {
padding: 4px 16px 16px 16px;
}
}
&__chevron {
color: hsla(var(--text-secondary));
transition: transform 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
}
.accordion__content[data-state='open'] {
animation: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
.accordion__content[data-state='closed'] {
animation: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
.accordion__trigger[data-state='open'] > .accordion__chevron {
transform: rotate(180deg);
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes slideUp {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}

View File

@ -1,83 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Badge, badgeConfig } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('@joi/core/Badge', () => {
it('renders with default props', () => {
render(<Badge>Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('badge')
expect(badge).toHaveClass('badge--primary')
expect(badge).toHaveClass('badge--medium')
expect(badge).toHaveClass('badge--solid')
})
it('applies custom className', () => {
render(<Badge className="custom-class">Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toHaveClass('custom-class')
})
it('renders with different themes', () => {
const themes = Object.keys(badgeConfig.variants.theme)
themes.forEach((theme) => {
render(<Badge theme={theme as any}>Test Badge {theme}</Badge>)
const badge = screen.getByText(`Test Badge ${theme}`)
expect(badge).toHaveClass(`badge--${theme}`)
})
})
it('renders with different variants', () => {
const variants = Object.keys(badgeConfig.variants.variant)
variants.forEach((variant) => {
render(<Badge variant={variant as any}>Test Badge {variant}</Badge>)
const badge = screen.getByText(`Test Badge ${variant}`)
expect(badge).toHaveClass(`badge--${variant}`)
})
})
it('renders with different sizes', () => {
const sizes = Object.keys(badgeConfig.variants.size)
sizes.forEach((size) => {
render(<Badge size={size as any}>Test Badge {size}</Badge>)
const badge = screen.getByText(`Test Badge ${size}`)
expect(badge).toHaveClass(`badge--${size}`)
})
})
it('fails when a new theme is added without updating the test', () => {
const expectedThemes = [
'primary',
'secondary',
'warning',
'success',
'info',
'destructive',
]
const actualThemes = Object.keys(badgeConfig.variants.theme)
expect(actualThemes).toEqual(expectedThemes)
})
it('fails when a new variant is added without updating the test', () => {
const expectedVariant = ['solid', 'soft', 'outline']
const actualVariants = Object.keys(badgeConfig.variants.variant)
expect(actualVariants).toEqual(expectedVariant)
})
it('fails when a new size is added without updating the test', () => {
const expectedSizes = ['small', 'medium', 'large']
const actualSizes = Object.keys(badgeConfig.variants.size)
expect(actualSizes).toEqual(expectedSizes)
})
it('fails when a new variant CVA is added without updating the test', () => {
const expectedVariantsCVA = ['theme', 'variant', 'size']
const actualVariant = Object.keys(badgeConfig.variants)
expect(actualVariant).toEqual(expectedVariantsCVA)
})
})

View File

@ -1,52 +0,0 @@
import React, { HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { twMerge } from 'tailwind-merge'
import './styles.scss'
export const badgeConfig = {
variants: {
theme: {
primary: 'badge--primary',
secondary: 'badge--secondary',
warning: 'badge--warning',
success: 'badge--success',
info: 'badge--info',
destructive: 'badge--destructive',
},
variant: {
solid: 'badge--solid',
soft: 'badge--soft',
outline: 'badge--outline',
},
size: {
small: 'badge--small',
medium: 'badge--medium',
large: 'badge--large',
},
},
defaultVariants: {
theme: 'primary' as const,
size: 'medium' as const,
variant: 'solid' as const,
},
}
const badgeVariants = cva('badge', badgeConfig)
export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
const Badge = ({ className, theme, size, variant, ...props }: BadgeProps) => {
return (
<div
className={twMerge(badgeVariants({ theme, size, variant, className }))}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -1,131 +0,0 @@
.badge {
@apply inline-flex items-center justify-center px-2 font-medium transition-all;
// Primary
&--primary {
color: hsla(var(--primary-fg));
background-color: hsla(var(--primary-bg));
// Variant soft primary
&.badge--soft {
background-color: hsla(var(--primary-bg-soft));
color: hsla(var(--primary-bg));
}
// Variant outline primary
&.badge--outline {
background-color: transparent;
border: 1px solid hsla(var(--primary-bg));
color: hsla(var(--primary-bg));
}
}
// Secondary
&--secondary {
background-color: hsla(var(--secondary-bg));
color: hsla(var(--secondary-fg));
&.badge--soft {
background-color: hsla(var(--secondary-bg-soft));
color: hsla(var(--secondary-bg));
}
// Variant outline secondary
&.badge--outline {
background-color: transparent;
border: 1px solid hsla(var(--secondary-bg));
}
}
// Destructive
&--destructive {
color: hsla(var(--destructive-fg));
background-color: hsla(var(--destructive-bg));
// Variant soft destructive
&.badge--soft {
background-color: hsla(var(--destructive-bg-soft));
color: hsla(var(--destructive-bg));
}
// Variant outline destructive
&.badge--outline {
background-color: transparent;
border: 1px solid hsla(var(--destructive-bg));
color: hsla(var(--destructive-bg));
}
}
// Success
&--success {
@apply text-white;
background-color: hsla(var(--success-bg));
// Variant soft success
&.badge--soft {
background-color: hsla(var(--success-bg-soft));
color: hsla(var(--success-bg));
}
// Variant outline success
&.badge--outline {
background-color: transparent;
border: 1px solid hsla(var(--success-bg));
color: hsla(var(--success-bg));
}
}
// Warning
&--warning {
@apply text-white;
background-color: hsla(var(--warning-bg));
// Variant soft warning
&.badge--soft {
background-color: hsla(var(--warning-bg-soft));
color: hsla(var(--warning-bg));
}
// Variant outline warning
&.badge--outline {
background-color: transparent;
border: 1px solid hsla(var(--warning-bg));
color: hsla(var(--warning-bg));
}
}
// Info
&--info {
@apply text-white;
background-color: hsla(var(--info-bg));
// Variant soft info
&.badge--soft {
background-color: hsla(var(--info-bg-soft));
color: hsla(var(--info-bg));
}
// Variant outline info
&.badge--outline {
background-color: transparent;
border: 1px solid hsla(var(--info-bg));
color: hsla(var(--info-bg));
}
}
// Size
&--small {
@apply h-5;
border-radius: 4px;
}
&--medium {
@apply h-6;
border-radius: 6px;
}
&--large {
@apply h-7;
border-radius: 8px;
}
}

View File

@ -1,90 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Button, buttonConfig } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('@joi/core/Button', () => {
it('renders with default props', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
expect(button).toHaveClass('btn btn--primary btn--medium btn--solid')
})
it('applies custom className', () => {
render(<Button className="custom-class">Test Button</Button>)
const badge = screen.getByText('Test Button')
expect(badge).toHaveClass('custom-class')
})
it('renders as a child component when asChild is true', () => {
render(
<Button asChild>
<a href="/">Link Button</a>
</Button>
)
const link = screen.getByRole('link', { name: /link button/i })
expect(link).toBeInTheDocument()
expect(link).toHaveClass('btn btn--primary btn--medium btn--solid')
})
it.each(Object.keys(buttonConfig.variants.theme))(
'renders with theme %s',
(theme) => {
render(<Button theme={theme as any}>Theme Button</Button>)
const button = screen.getByRole('button', { name: /theme button/i })
expect(button).toHaveClass(`btn btn--${theme}`)
}
)
it.each(Object.keys(buttonConfig.variants.variant))(
'renders with variant %s',
(variant) => {
render(<Button variant={variant as any}>Variant Button</Button>)
const button = screen.getByRole('button', { name: /variant button/i })
expect(button).toHaveClass(`btn btn--${variant}`)
}
)
it.each(Object.keys(buttonConfig.variants.size))(
'renders with size %s',
(size) => {
render(<Button size={size as any}>Size Button</Button>)
const button = screen.getByRole('button', { name: /size button/i })
expect(button).toHaveClass(`btn btn--${size}`)
}
)
it('renders with block prop', () => {
render(<Button block>Block Button</Button>)
const button = screen.getByRole('button', { name: /block button/i })
expect(button).toHaveClass('btn btn--block')
})
it('fails when a new theme is added without updating the test', () => {
const expectedThemes = ['primary', 'ghost', 'icon', 'destructive']
const actualThemes = Object.keys(buttonConfig.variants.theme)
expect(actualThemes).toEqual(expectedThemes)
})
it('fails when a new variant is added without updating the test', () => {
const expectedVariant = ['solid', 'soft', 'outline']
const actualVariants = Object.keys(buttonConfig.variants.variant)
expect(actualVariants).toEqual(expectedVariant)
})
it('fails when a new size is added without updating the test', () => {
const expectedSizes = ['small', 'medium', 'large']
const actualSizes = Object.keys(buttonConfig.variants.size)
expect(actualSizes).toEqual(expectedSizes)
})
it('fails when a new variant CVA is added without updating the test', () => {
const expectedVariantsCVA = ['theme', 'variant', 'size', 'block']
const actualVariant = Object.keys(buttonConfig.variants)
expect(actualVariant).toEqual(expectedVariantsCVA)
})
})

View File

@ -1,65 +0,0 @@
import React, { forwardRef, ButtonHTMLAttributes } from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { twMerge } from 'tailwind-merge'
import './styles.scss'
export const buttonConfig = {
variants: {
theme: {
primary: 'btn--primary',
ghost: 'btn--ghost',
icon: 'btn--icon',
destructive: 'btn--destructive',
},
variant: {
solid: 'btn--solid',
soft: 'btn--soft',
outline: 'btn--outline',
},
size: {
small: 'btn--small',
medium: 'btn--medium',
large: 'btn--large',
},
block: {
true: 'btn--block',
},
},
defaultVariants: {
theme: 'primary' as const,
size: 'medium' as const,
variant: 'solid' as const,
block: false as const,
},
}
const buttonVariants = cva('btn', buttonConfig)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, theme, size, variant, block, asChild = false, ...props },
ref
) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={twMerge(
buttonVariants({ theme, size, variant, block, className })
)}
ref={ref}
{...props}
/>
)
}
)
export { Button }

View File

@ -1,134 +0,0 @@
.btn {
@apply inline-flex items-center justify-center px-4 font-semibold transition-all;
&:focus,
&:focus-within {
@apply outline-2 outline-offset-4;
}
&:hover {
filter: brightness(95%);
}
// Primary
&--primary {
color: hsla(var(--primary-fg));
background-color: hsla(var(--primary-bg)) !important;
&:hover {
filter: brightness(65%);
}
// Variant soft primary
&.btn--soft {
background-color: hsla(var(--primary-bg-soft)) !important;
color: hsla(var(--primary-bg));
}
// Variant outline primary
&.btn--outline {
background-color: transparent !important;
border: 1px solid hsla(var(--primary-bg));
color: hsla(var(--primary-bg));
}
}
// Ghost
&--ghost {
background-color: transparent !important;
&.btn--soft {
background-color: transparent !important;
}
// Variant outline ghost
&.btn--outline {
background-color: transparent !important;
border: 1px solid hsla(var(--ghost-border));
}
}
// Destructive
&--destructive {
color: hsla(var(--destructive-fg));
background-color: hsla(var(--destructive-bg)) !important;
&:hover {
filter: brightness(65%);
}
// Variant soft destructive
&.btn--soft {
background-color: hsla(var(--destructive-bg-soft)) !important;
color: hsla(var(--destructive-bg));
}
// Variant outline destructive
&.btn--outline {
background-color: transparent !important;
border: 1px solid hsla(var(--destructive-bg));
color: hsla(var(--destructive-bg));
}
}
// Disabled
&:disabled {
color: hsla(var(--disabled-fg));
background-color: hsla(var(--disabled-bg)) !important;
cursor: not-allowed;
&:hover {
filter: brightness(100%);
}
}
// Icon
&--icon {
width: 24px;
height: 24px;
padding: 2px;
&:hover {
background-color: hsla(var(--icon-bg)) !important;
}
&.btn--outline {
background-color: transparent !important;
border: 1px solid hsla(var(--icon-border));
&:hover {
background-color: hsla(var(--icon-bg)) !important;
}
}
}
// Size
&--small {
@apply h-6 px-2;
font-size: 12px;
border-radius: 4px;
&.btn--icon {
width: 24px;
height: 24px;
padding: 2px;
}
}
&--medium {
@apply h-8;
border-radius: 6px;
&.btn--icon {
width: 24px;
height: 24px;
padding: 2px;
}
}
&--large {
@apply h-9;
border-radius: 8px;
&.btn--icon {
width: 24px;
height: 24px;
padding: 2px;
}
}
&--block {
@apply w-full;
}
}

View File

@ -1,50 +0,0 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Checkbox } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('@joi/core/Checkbox', () => {
it('renders correctly with label', () => {
render(<Checkbox id="test-checkbox" label="Test Checkbox" />)
expect(screen.getByLabelText('Test Checkbox')).toBeInTheDocument()
})
it('renders with helper description', () => {
render(<Checkbox id="test-checkbox" helperDescription="Helper text" />)
expect(screen.getByText('Helper text')).toBeInTheDocument()
})
it('renders error message when provided', () => {
render(<Checkbox id="test-checkbox" errorMessage="Error occurred" />)
expect(screen.getByText('Error occurred')).toBeInTheDocument()
})
it('calls onChange when clicked', () => {
const mockOnChange = jest.fn()
render(
<Checkbox
id="test-checkbox"
label="Test Checkbox"
onChange={mockOnChange}
/>
)
fireEvent.click(screen.getByLabelText('Test Checkbox'))
expect(mockOnChange).toHaveBeenCalledTimes(1)
})
it('applies custom className', () => {
render(<Checkbox id="test-checkbox" className="custom-class" />)
expect(screen.getByRole('checkbox').parentElement).toHaveClass(
'custom-class'
)
})
it('disables the checkbox when disabled prop is true', () => {
render(<Checkbox id="test-checkbox" label="Disabled Checkbox" disabled />)
expect(screen.getByLabelText('Disabled Checkbox')).toBeDisabled()
})
})

View File

@ -1,51 +0,0 @@
import React, { ChangeEvent, InputHTMLAttributes, ReactNode } from 'react'
import { twMerge } from 'tailwind-merge'
import './styles.scss'
export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
disabled?: boolean
className?: string
label?: ReactNode
helperDescription?: ReactNode
errorMessage?: string
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
}
const Checkbox = ({
id,
name,
checked,
disabled,
label,
defaultChecked,
helperDescription,
errorMessage,
className,
onChange,
...props
}: CheckboxProps) => {
return (
<div className={twMerge('checkbox', className)}>
<input
id={id}
type="checkbox"
name={name}
defaultChecked={defaultChecked}
checked={checked}
disabled={disabled}
onChange={onChange}
{...props}
/>
<div>
<label htmlFor={id} className="checkbox__label">
{label}
</label>
<p className="checkbox__helper">{helperDescription}</p>
{errorMessage && <p className="checkbox__error">{errorMessage}</p>}
</div>
</div>
)
}
export { Checkbox }

View File

@ -1,51 +0,0 @@
.checkbox {
@apply inline-flex items-start space-x-2;
> input[type='checkbox'] {
@apply flex h-4 w-4 flex-shrink-0 cursor-pointer appearance-none items-center justify-center;
background-color: transparent;
margin-top: 1px;
border: 1px solid hsla(var(--app-border));
border-radius: 4px;
&:focus,
&:focus-within {
@apply outline-2 outline-offset-4;
}
&:checked {
background-color: hsla(var(--primary-bg));
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E");
}
&:disabled {
background-color: hsla(var(----disabled-bg));
color: hsla(var(--disabled-fg));
&:checked {
background-color: hsla(var(--primary-bg));
@apply cursor-not-allowed opacity-50;
}
& + div > .checkbox__label {
@apply cursor-not-allowed opacity-50;
}
}
}
&__helper {
font-size: 12px;
}
&__error {
color: hsla(var(--destructive-bg));
}
&__label {
@apply inline-block cursor-pointer;
}
&:disabled {
background-color: hsla(var(----disabled-bg));
color: hsla(var(--disabled-fg));
}
}

View File

@ -1,45 +0,0 @@
import React, { Fragment, PropsWithChildren, ReactNode } from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import './styles.scss'
import { twMerge } from 'tailwind-merge'
type Props = {
options?: { name: ReactNode; value: string; suffix?: ReactNode }[]
className?: string
onValueChanged?: (value: string) => void
}
const Dropdown = (props: PropsWithChildren & Props) => {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>{props.children}</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={twMerge(props.className, 'DropdownMenuContent')}
sideOffset={0}
align="end"
>
{props.options?.map((e, i) => (
<Fragment key={e.value}>
{i !== 0 && (
<DropdownMenu.Separator className="DropdownMenuSeparator" />
)}
<DropdownMenu.Item
className="DropdownMenuItem"
onClick={() => props.onValueChanged?.(e.value)}
>
{e.name}
<div />
{e.suffix}
</DropdownMenu.Item>
</Fragment>
))}
<DropdownMenu.Arrow className="DropdownMenuArrow" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}
export { Dropdown }

View File

@ -1,155 +0,0 @@
.DropdownMenuContent,
.DropdownMenuSubContent {
min-width: 220px;
background-color: white;
border-radius: 6px;
overflow: hidden;
padding: 0px;
box-shadow:
0px 10px 38px -10px rgba(22, 23, 24, 0.35),
0px 10px 20px -15px rgba(22, 23, 24, 0.2);
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
.DropdownMenuContent[data-side='top'],
.DropdownMenuSubContent[data-side='top'] {
animation-name: slideDownAndFade;
}
.DropdownMenuContent[data-side='right'],
.DropdownMenuSubContent[data-side='right'] {
animation-name: slideLeftAndFade;
}
.DropdownMenuContent[data-side='bottom'],
.DropdownMenuSubContent[data-side='bottom'] {
animation-name: slideUpAndFade;
}
.DropdownMenuContent[data-side='left'],
.DropdownMenuSubContent[data-side='left'] {
animation-name: slideRightAndFade;
}
.DropdownMenuItem {
padding: 14px;
cursor: pointer;
outline: none;
flex: 1;
display: flex;
justify-content: space-between; /* Distribute space between children */
align-items: center; /* Optional: Align items vertically */
gap: 16px;
border-color: hsla(var(--app-border));
}
.DropdownMenuCheckboxItem,
.DropdownMenuRadioItem,
.DropdownMenuSubTrigger {
font-size: 13px;
line-height: 1;
border-radius: 3px;
display: flex;
align-items: center;
height: 25px;
padding: 0 0;
position: relative;
padding-left: 25px;
user-select: none;
outline: none;
}
.DropdownMenuItem[data-disabled],
.DropdownMenuCheckboxItem[data-disabled],
.DropdownMenuRadioItem[data-disabled],
.DropdownMenuSubTrigger[data-disabled] {
pointer-events: none;
}
.DropdownMenuItem[data-highlighted],
.DropdownMenuCheckboxItem[data-highlighted],
.DropdownMenuRadioItem[data-highlighted],
.DropdownMenuSubTrigger[data-highlighted] {
background-color: hsla(var(--secondary-bg));
}
.DropdownMenuSeparator {
height: 1px;
width: '100%';
background-color: hsla(var(--app-border));
}
.DropdownMenuItem::hover {
background-color: hsla(var(--secondary-bg));
}
.DropdownMenuLabel {
padding-left: 25px;
font-size: 12px;
line-height: 25px;
color: var(--mauve-11);
}
.DropdownMenuItemIndicator {
position: absolute;
left: 0;
width: 25px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.DropdownMenuArrow {
fill: white;
}
.RightSlot {
margin-left: auto;
padding-left: 20px;
color: var(--mauve-11);
}
[data-highlighted] > .RightSlot {
color: white;
}
[data-disabled] .RightSlot {
color: var(--mauve-8);
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideRightAndFade {
from {
opacity: 0;
transform: translateX(-2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideDownAndFade {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
opacity: 0;
transform: translateX(2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@ -1,53 +0,0 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Input } from './index'
// Mock the styles import
jest.mock('./styles.scss', () => ({}))
describe('@joi/core/Input', () => {
it('renders correctly', () => {
render(<Input placeholder="Test input" />)
expect(screen.getByPlaceholderText('Test input')).toBeInTheDocument()
})
it('applies custom className', () => {
render(<Input className="custom-class" />)
expect(screen.getByRole('textbox')).toHaveClass('custom-class')
})
it('aligns text to the right when textAlign prop is set', () => {
render(<Input textAlign="right" />)
expect(screen.getByRole('textbox')).toHaveClass('text-right')
})
it('renders prefix icon when provided', () => {
render(<Input prefixIcon={<span data-testid="prefix-icon">Prefix</span>} />)
expect(screen.getByTestId('prefix-icon')).toBeInTheDocument()
})
it('renders suffix icon when provided', () => {
render(<Input suffixIcon={<span data-testid="suffix-icon">Suffix</span>} />)
expect(screen.getByTestId('suffix-icon')).toBeInTheDocument()
})
it('renders clear icon when clearable is true', () => {
render(<Input clearable />)
expect(screen.getByTestId('cross-2-icon')).toBeInTheDocument()
})
it('calls onClick when input is clicked', () => {
const onClick = jest.fn()
render(<Input onClick={onClick} />)
fireEvent.click(screen.getByRole('textbox'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('calls onClear when clear icon is clicked', () => {
const onClear = jest.fn()
render(<Input clearable onClear={onClear} />)
fireEvent.click(screen.getByTestId('cross-2-icon'))
expect(onClear).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,64 +0,0 @@
import React, { ReactNode, forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'
import './styles.scss'
import { Cross2Icon } from '@radix-ui/react-icons'
export interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
textAlign?: 'left' | 'right'
prefixIcon?: ReactNode
suffixIcon?: ReactNode
onCLick?: () => void
clearable?: boolean
onClear?: () => void
}
const Input = forwardRef<HTMLInputElement, Props>(
(
{
className,
type,
textAlign,
prefixIcon,
suffixIcon,
onClick,
onClear,
clearable,
...props
},
ref
) => {
return (
<div className="input__wrapper">
{prefixIcon && (
<div className="input__prefix-icon" onClick={onClick}>
{prefixIcon}
</div>
)}
{suffixIcon && (
<div className="input__suffix-icon" onClick={onClick}>
{suffixIcon}
</div>
)}
{clearable && (
<div className="input__clear-icon" onClick={onClear}>
<Cross2Icon data-testid="cross-2-icon" className="text-red-200" />
</div>
)}
<input
type={type}
className={twMerge(
'input',
className,
textAlign === 'right' && 'text-right'
)}
ref={ref}
onClick={onClick}
{...props}
/>
</div>
)
}
)
export { Input }

View File

@ -1,50 +0,0 @@
.input {
background-color: hsla(var(--input-bg));
border: 1px solid hsla(var(--app-border));
@apply inline-flex h-8 w-full items-center rounded-md border px-3 transition-colors;
@apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-1 focus-visible:ring-[hsla(var(--primary-bg))] focus-visible:ring-offset-0;
@apply file:border-0 file:bg-transparent file:font-medium;
@apply hover:border-[hsla(var(--primary-bg))];
&__wrapper {
position: relative;
}
&.text-right {
text-align: right;
}
&::placeholder {
color: hsla(var(--input-placeholder));
}
&:disabled {
color: hsla(var(--disabled-fg));
background-color: hsla(var(--disabled-bg));
cursor: not-allowed;
border: none;
}
&__prefix-icon {
@apply absolute left-3 top-1/2 -translate-y-1/2 cursor-pointer;
color: hsla(var(--input-icon));
+ .input {
padding-left: 32px;
}
}
&__suffix-icon {
@apply absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer;
color: hsla(var(--input-icon));
+ .input {
padding-right: 32px;
}
}
&__clear-icon {
@apply absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer;
color: hsla(var(--input-icon));
+ .input {
padding: 0 32px;
}
}
}

View File

@ -1,78 +0,0 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Modal } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('Modal', () => {
it('renders the modal with trigger and content', () => {
render(
<Modal
trigger={<button>Open Modal</button>}
content={<div>Modal Content</div>}
/>
)
expect(screen.getByText('Open Modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('Open Modal'))
expect(screen.getByText('Modal Content')).toBeInTheDocument()
})
it('renders the modal with title', () => {
render(
<Modal
trigger={<button>Open Modal</button>}
content={<div>Modal Content</div>}
title="Modal Title"
/>
)
fireEvent.click(screen.getByText('Open Modal'))
expect(screen.getByText('Modal Title')).toBeInTheDocument()
})
it('renders full page modal', () => {
render(
<Modal
trigger={<button>Open Modal</button>}
content={<div>Modal Content</div>}
fullPage
/>
)
fireEvent.click(screen.getByText('Open Modal'))
expect(screen.getByRole('dialog')).toHaveClass('modal__content--fullpage')
})
it('hides close button when hideClose is true', () => {
render(
<Modal
trigger={<button>Open Modal</button>}
content={<div>Modal Content</div>}
hideClose
/>
)
fireEvent.click(screen.getByText('Open Modal'))
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('calls onOpenChange when opening and closing the modal', () => {
const onOpenChangeMock = jest.fn()
render(
<Modal
trigger={<button>Open Modal</button>}
content={<div>Modal Content</div>}
onOpenChange={onOpenChangeMock}
/>
)
fireEvent.click(screen.getByText('Open Modal'))
expect(onOpenChangeMock).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByLabelText('Close'))
expect(onOpenChangeMock).toHaveBeenCalledWith(false)
})
})

View File

@ -1,59 +0,0 @@
import React, { ReactNode } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { Cross2Icon } from '@radix-ui/react-icons'
import './styles.scss'
import { twMerge } from 'tailwind-merge'
type Props = {
trigger?: ReactNode
content: ReactNode
open?: boolean
className?: string
fullPage?: boolean
hideClose?: boolean
title?: ReactNode
onOpenChange?: (open: boolean) => void
}
const ModalClose = DialogPrimitive.Close
const Modal = ({
trigger,
content,
open,
title,
fullPage,
className,
onOpenChange,
hideClose,
}: Props) => (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="modal__overlay" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={twMerge(
'modal__content',
fullPage && 'modal__content--fullpage',
className
)}
>
<DialogPrimitive.Title className="modal__title">
{title}
</DialogPrimitive.Title>
{content}
{!hideClose && (
<ModalClose asChild>
<button className="modal__close-icon" aria-label="Close">
<Cross2Icon />
</button>
</ModalClose>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
export { Modal, ModalClose }

View File

@ -1,85 +0,0 @@
/* reset */
button,
fieldset,
.modal {
&__overlay {
background-color: hsla(var(--modal-overlay));
z-index: 200;
position: fixed;
inset: 0;
animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&__content {
color: hsla(var(--modal-fg));
overflow: auto;
background-color: hsla(var(--modal-bg));
border-radius: 8px;
font-size: 14px;
position: fixed;
z-index: 300;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50vw;
max-width: 560px;
max-height: 85vh;
padding: 16px;
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
border: 1px solid hsla(var(--app-border));
@apply w-full;
&--fullpage {
max-width: none;
width: 90vw;
max-height: 90vh;
}
&:focus {
outline: none;
}
}
&__title {
@apply leading-relaxed;
margin: 0 0 8px 0;
padding-right: 16px;
font-weight: 600;
color: hsla(var(--modal-fg));
font-size: 18px;
}
&__close-icon {
font-family: inherit;
border-radius: 100%;
height: 24px;
width: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
color: hsla(var(--modal-fg));
position: absolute;
top: 8px;
right: 16px;
}
}
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes contentShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View File

@ -1,55 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Progress } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('@joi/core/Progress', () => {
it('renders with default props', () => {
render(<Progress value={50} />)
const progressElement = screen.getByRole('progressbar')
expect(progressElement).toBeInTheDocument()
expect(progressElement).toHaveClass('progress')
expect(progressElement).toHaveClass('progress--medium')
expect(progressElement).toHaveAttribute('aria-valuenow', '50')
})
it('applies custom className', () => {
render(<Progress value={50} className="custom-class" />)
const progressElement = screen.getByRole('progressbar')
expect(progressElement).toHaveClass('custom-class')
})
it('renders with different sizes', () => {
const { rerender } = render(<Progress value={50} size="small" />)
let progressElement = screen.getByRole('progressbar')
expect(progressElement).toHaveClass('progress--small')
rerender(<Progress value={50} size="large" />)
progressElement = screen.getByRole('progressbar')
expect(progressElement).toHaveClass('progress--large')
})
it('sets the correct transform style based on value', () => {
render(<Progress value={75} />)
const progressElement = screen.getByRole('progressbar')
const indicatorElement = progressElement.firstChild as HTMLElement
expect(indicatorElement).toHaveStyle('transform: translateX(-25%)')
})
it('handles edge cases for value', () => {
const { rerender } = render(<Progress value={0} />)
let progressElement = screen.getByRole('progressbar')
let indicatorElement = progressElement.firstChild as HTMLElement
expect(indicatorElement).toHaveStyle('transform: translateX(-100%)')
expect(progressElement).toHaveAttribute('aria-valuenow', '0')
rerender(<Progress value={100} />)
progressElement = screen.getByRole('progressbar')
indicatorElement = progressElement.firstChild as HTMLElement
expect(indicatorElement).toHaveStyle('transform: translateX(-0%)')
expect(progressElement).toHaveAttribute('aria-valuenow', '100')
})
})

View File

@ -1,46 +0,0 @@
import React, { HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { twMerge } from 'tailwind-merge'
import './styles.scss'
const progressVariants = cva('progress', {
variants: {
size: {
small: 'progress--small',
medium: 'progress--medium',
large: 'progress--large',
},
},
defaultVariants: {
size: 'medium',
},
})
export interface ProgressProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof progressVariants> {
value: number
}
const Progress = ({ className, size, value, ...props }: ProgressProps) => {
return (
<div
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
className={twMerge(progressVariants({ size, className }))}
{...props}
>
<div
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
className="progress--indicator"
/>
</div>
)
}
export { Progress }

View File

@ -1,25 +0,0 @@
.progress {
background-color: hsla(var(--progress-track-bg));
border-radius: 8px;
position: relative;
overflow: hidden;
@apply transition-all;
&--indicator {
background-color: hsla(var(--primary-bg));
position: absolute;
border-radius: 8px;
width: 100%;
height: 100%;
}
&--small {
height: 6px;
}
&--medium {
@apply h-2;
}
&--large {
@apply h-3;
}
}

View File

@ -1,47 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { ScrollArea } from './index'
declare const global: typeof globalThis
// Mock the styles
jest.mock('./styles.scss', () => ({}))
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverMock
describe('@joi/core/ScrollArea', () => {
it('renders children correctly', () => {
render(
<ScrollArea>
<div data-testid="child">Test Content</div>
</ScrollArea>
)
const child = screen.getByTestId('child')
expect(child).toBeInTheDocument()
expect(child).toHaveTextContent('Test Content')
})
it('applies custom className', () => {
const { container } = render(<ScrollArea className="custom-class" />)
const root = container.firstChild as HTMLElement
expect(root).toHaveClass('scroll-area__root')
expect(root).toHaveClass('custom-class')
})
it('forwards ref to the Viewport component', () => {
const ref = React.createRef<HTMLDivElement>()
render(<ScrollArea ref={ref} />)
expect(ref.current).toBeInstanceOf(HTMLDivElement)
expect(ref.current).toHaveClass('scroll-area__viewport')
})
})

View File

@ -1,39 +0,0 @@
import React, { PropsWithChildren, forwardRef } from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { twMerge } from 'tailwind-merge'
import './styles.scss'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, onScroll, ...props }, ref) => (
<ScrollAreaPrimitive.Root
type="auto"
className={twMerge('scroll-area__root', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
className="scroll-area__viewport"
ref={ref}
onScroll={onScroll}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Scrollbar
className="scroll-area__bar"
orientation="horizontal"
>
<ScrollAreaPrimitive.Thumb />
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Scrollbar
className="scroll-area__bar"
orientation="vertical"
>
<ScrollAreaPrimitive.Thumb className="scroll-area__thumb" />
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Corner className="scroll-area__corner" />
</ScrollAreaPrimitive.Root>
))
export { ScrollArea }

View File

@ -1,53 +0,0 @@
.scroll-area {
position: relative;
z-index: 999;
&__root {
width: 200px;
height: 225px;
overflow: hidden;
}
&__viewport {
width: 100%;
height: 100%;
border-radius: inherit;
}
&__bar {
display: flex;
user-select: none;
touch-action: none;
padding: 1px;
background: hsla(var(--scrollbar-tracker));
transition: background 160ms ease-out;
}
&__thumb {
flex: 1;
background: hsla(var(--scrollbar-thumb));
border-radius: 20px;
position: relative;
::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
min-width: 44px;
min-height: 44px;
}
}
}
.scroll-area__bar[data-orientation='vertical'] {
width: 8px;
}
.scroll-area__bar[data-orientation='horizontal'] {
flex-direction: column;
height: 8px;
}

View File

@ -1,107 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Select } from './index'
import '@testing-library/jest-dom'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
jest.mock('tailwind-merge', () => ({
twMerge: (...classes: string[]) => classes.filter(Boolean).join(' '),
}))
const mockOnValueChange = jest.fn()
jest.mock('@radix-ui/react-select', () => ({
Root: ({
children,
onValueChange,
}: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => {
mockOnValueChange.mockImplementation(onValueChange)
return <div data-testid="select-root">{children}</div>
},
Trigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button data-testid="select-trigger" className={className}>
{children}
</button>
),
Value: ({ placeholder }: { placeholder?: string }) => (
<span data-testid="select-value">{placeholder}</span>
),
Icon: ({ children }: { children: React.ReactNode }) => (
<span data-testid="select-icon">{children}</span>
),
Portal: ({ children }: { children: React.ReactNode }) => (
<div data-testid="select-portal">{children}</div>
),
Content: ({ children }: { children: React.ReactNode }) => (
<div data-testid="select-content">{children}</div>
),
Viewport: ({ children }: { children: React.ReactNode }) => (
<div data-testid="select-viewport">{children}</div>
),
Item: ({ children, value }: { children: React.ReactNode; value: string }) => (
<div
data-testid={`select-item-${value}`}
onClick={() => mockOnValueChange(value)}
>
{children}
</div>
),
ItemText: ({ children }: { children: React.ReactNode }) => (
<span data-testid="select-item-text">{children}</span>
),
ItemIndicator: ({ children }: { children: React.ReactNode }) => (
<span data-testid="select-item-indicator">{children}</span>
),
Arrow: () => <div data-testid="select-arrow" />,
}))
describe('@joi/core/Select', () => {
const options = [
{ name: 'Option 1', value: 'option1' },
{ name: 'Option 2', value: 'option2' },
]
it('renders with placeholder', () => {
render(<Select placeholder="Select an option" options={options} />)
expect(screen.getByTestId('select-value')).toHaveTextContent(
'Select an option'
)
})
it('renders options', () => {
render(<Select options={options} />)
expect(screen.getByTestId('select-item-option1')).toBeInTheDocument()
expect(screen.getByTestId('select-item-option2')).toBeInTheDocument()
})
it('calls onValueChange when an option is selected', async () => {
const user = userEvent.setup()
const onValueChange = jest.fn()
render(<Select options={options} onValueChange={onValueChange} />)
await user.click(screen.getByTestId('select-trigger'))
await user.click(screen.getByTestId('select-item-option1'))
expect(onValueChange).toHaveBeenCalledWith('option1')
})
it('applies disabled class when disabled prop is true', () => {
render(<Select options={options} disabled />)
expect(screen.getByTestId('select-trigger')).toHaveClass('select__disabled')
})
it('applies block class when block prop is true', () => {
render(<Select options={options} block />)
expect(screen.getByTestId('select-trigger')).toHaveClass('w-full')
})
})

Some files were not shown because too many files have changed in this diff Show More