chore: split app to commons and web (squash commit)
This commit is contained in:
71
packages/hoppscotch-common/src/composables/auth.ts
Normal file
71
packages/hoppscotch-common/src/composables/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
currentUser$,
|
||||
HoppUser,
|
||||
AuthEvent,
|
||||
authEvents$,
|
||||
authIdToken$,
|
||||
} from "@helpers/fb/auth"
|
||||
import {
|
||||
map,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
Subscription,
|
||||
combineLatestWith,
|
||||
} from "rxjs"
|
||||
import { onBeforeUnmount, onMounted } from "vue"
|
||||
|
||||
/**
|
||||
* A Vue composable function that is called when the auth status
|
||||
* is being updated to being logged in (fired multiple times),
|
||||
* this is also called on component mount if the login
|
||||
* was already resolved before mount.
|
||||
*/
|
||||
export function onLoggedIn(exec: (user: HoppUser) => void) {
|
||||
let sub: Subscription | null = null
|
||||
|
||||
onMounted(() => {
|
||||
sub = currentUser$
|
||||
.pipe(
|
||||
// We don't consider the state as logged in unless we also have an id token
|
||||
combineLatestWith(authIdToken$),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
filter(([_, token]) => !!token),
|
||||
map((user) => !!user), // Get a logged in status (true or false)
|
||||
distinctUntilChanged(), // Don't propagate unless the status updates
|
||||
filter((x) => x) // Don't propagate unless it is logged in
|
||||
)
|
||||
.subscribe(() => {
|
||||
exec(currentUser$.value!)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sub?.unsubscribe()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A Vue composable function that calls its param function
|
||||
* when a new event (login, logout etc.) happens in
|
||||
* the auth system.
|
||||
*
|
||||
* NOTE: Unlike `onLoggedIn` for which the callback will be called once on mount with the current state,
|
||||
* here the callback will only be called on authentication event occurances.
|
||||
* You might want to check the auth state from an `onMounted` hook or something
|
||||
* if you want to access the initial state
|
||||
*
|
||||
* @param func A function which accepts an event
|
||||
*/
|
||||
export function onAuthEvent(func: (ev: AuthEvent) => void) {
|
||||
let sub: Subscription | null = null
|
||||
|
||||
onMounted(() => {
|
||||
sub = authEvents$.subscribe((ev) => {
|
||||
func(ev)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sub?.unsubscribe()
|
||||
})
|
||||
}
|
||||
372
packages/hoppscotch-common/src/composables/codemirror.ts
Normal file
372
packages/hoppscotch-common/src/composables/codemirror.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
keymap,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
placeholder,
|
||||
} from "@codemirror/view"
|
||||
import {
|
||||
Extension,
|
||||
EditorState,
|
||||
Compartment,
|
||||
EditorSelection,
|
||||
} from "@codemirror/state"
|
||||
import {
|
||||
Language,
|
||||
LanguageSupport,
|
||||
StreamLanguage,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language"
|
||||
import { defaultKeymap, indentLess, insertTab } from "@codemirror/commands"
|
||||
import { Completion, autocompletion } from "@codemirror/autocomplete"
|
||||
import { linter } from "@codemirror/lint"
|
||||
import { watch, ref, Ref, onMounted, onBeforeUnmount } from "vue"
|
||||
import { javascriptLanguage } from "@codemirror/lang-javascript"
|
||||
import { xmlLanguage } from "@codemirror/lang-xml"
|
||||
import { jsonLanguage } from "@codemirror/lang-json"
|
||||
import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql"
|
||||
import { html } from "@codemirror/legacy-modes/mode/xml"
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell"
|
||||
import { yaml } from "@codemirror/legacy-modes/mode/yaml"
|
||||
import { isJSONContentType } from "@helpers/utils/contenttypes"
|
||||
import { useStreamSubscriber } from "@composables/stream"
|
||||
import { Completer } from "@helpers/editor/completion"
|
||||
import { LinterDefinition } from "@helpers/editor/linting/linter"
|
||||
import {
|
||||
basicSetup,
|
||||
baseTheme,
|
||||
baseHighlightStyle,
|
||||
} from "@helpers/editor/themes/baseTheme"
|
||||
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
|
||||
// TODO: Migrate from legacy mode
|
||||
|
||||
type ExtendedEditorConfig = {
|
||||
mode: string
|
||||
placeholder: string
|
||||
readOnly: boolean
|
||||
lineWrapping: boolean
|
||||
}
|
||||
|
||||
type CodeMirrorOptions = {
|
||||
extendedEditorConfig: Partial<ExtendedEditorConfig>
|
||||
linter: LinterDefinition | null
|
||||
completer: Completer | null
|
||||
|
||||
// NOTE: This property is not reactive
|
||||
environmentHighlights: boolean
|
||||
}
|
||||
|
||||
const hoppCompleterExt = (completer: Completer): Extension => {
|
||||
return autocompletion({
|
||||
override: [
|
||||
async (context) => {
|
||||
// Expensive operation! Disable on bigger files ?
|
||||
const text = context.state.doc.toJSON().join(context.state.lineBreak)
|
||||
|
||||
const line = context.state.doc.lineAt(context.pos)
|
||||
const lineStart = line.from
|
||||
const lineNo = line.number - 1
|
||||
const ch = context.pos - lineStart
|
||||
|
||||
// Only do trigger on type when typing a word token, else stop (unless explicit)
|
||||
if (!context.matchBefore(/\w+/) && !context.explicit)
|
||||
return {
|
||||
from: context.pos,
|
||||
options: [],
|
||||
}
|
||||
|
||||
const result = await completer(text, { line: lineNo, ch })
|
||||
|
||||
// Use more completion features ?
|
||||
const completions =
|
||||
result?.completions.map<Completion>((comp) => ({
|
||||
label: comp.text,
|
||||
detail: comp.meta,
|
||||
})) ?? []
|
||||
|
||||
return {
|
||||
from: context.state.wordAt(context.pos)?.from ?? context.pos,
|
||||
options: completions,
|
||||
}
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => {
|
||||
return linter(async (view) => {
|
||||
if (!hoppLinter) return []
|
||||
|
||||
// Requires full document scan, hence expensive on big files, force disable on big files ?
|
||||
const linterResult = await hoppLinter(
|
||||
view.state.doc.toJSON().join(view.state.lineBreak)
|
||||
)
|
||||
|
||||
return linterResult.map((result) => {
|
||||
const startPos =
|
||||
view.state.doc.line(result.from.line).from + result.from.ch - 1
|
||||
const endPos = view.state.doc.line(result.to.line).from + result.to.ch - 1
|
||||
|
||||
return {
|
||||
from: startPos < 0 ? 0 : startPos,
|
||||
to: endPos > view.state.doc.length ? view.state.doc.length : endPos,
|
||||
message: result.message,
|
||||
severity: result.severity,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const hoppLang = (
|
||||
language: Language | undefined,
|
||||
linter?: LinterDefinition | undefined,
|
||||
completer?: Completer | undefined
|
||||
): Extension | LanguageSupport => {
|
||||
const exts: Extension[] = []
|
||||
|
||||
exts.push(hoppLinterExt(linter))
|
||||
if (completer) exts.push(hoppCompleterExt(completer))
|
||||
|
||||
return language ? new LanguageSupport(language, exts) : exts
|
||||
}
|
||||
|
||||
const getLanguage = (langMime: string): Language | null => {
|
||||
if (isJSONContentType(langMime)) {
|
||||
return jsonLanguage
|
||||
} else if (langMime === "application/javascript") {
|
||||
return javascriptLanguage
|
||||
} else if (langMime === "graphql") {
|
||||
return GQLLanguage
|
||||
} else if (langMime === "application/xml") {
|
||||
return xmlLanguage
|
||||
} else if (langMime === "htmlmixed") {
|
||||
return StreamLanguage.define(html)
|
||||
} else if (langMime === "application/x-sh") {
|
||||
return StreamLanguage.define(shell)
|
||||
} else if (langMime === "text/x-yaml") {
|
||||
return StreamLanguage.define(yaml)
|
||||
}
|
||||
|
||||
// None matched, so return null
|
||||
return null
|
||||
}
|
||||
|
||||
const getEditorLanguage = (
|
||||
langMime: string,
|
||||
linter: LinterDefinition | undefined,
|
||||
completer: Completer | undefined
|
||||
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
|
||||
|
||||
export function useCodemirror(
|
||||
el: Ref<any | null>,
|
||||
value: Ref<string | undefined>,
|
||||
options: CodeMirrorOptions
|
||||
): { cursor: Ref<{ line: number; ch: number }> } {
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const language = new Compartment()
|
||||
const lineWrapping = new Compartment()
|
||||
const placeholderConfig = new Compartment()
|
||||
|
||||
const cachedCursor = ref({
|
||||
line: 0,
|
||||
ch: 0,
|
||||
})
|
||||
const cursor = ref({
|
||||
line: 0,
|
||||
ch: 0,
|
||||
})
|
||||
|
||||
const cachedValue = ref(value.value)
|
||||
|
||||
const view = ref<EditorView>()
|
||||
|
||||
const environmentTooltip = options.environmentHighlights
|
||||
? new HoppEnvironmentPlugin(subscribeToStream, view)
|
||||
: null
|
||||
|
||||
const initView = (el: any) => {
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
baseTheme,
|
||||
syntaxHighlighting(baseHighlightStyle, { fallback: true }),
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate) {
|
||||
const cursorPos = update.state.selection.main.head
|
||||
const line = update.state.doc.lineAt(cursorPos)
|
||||
|
||||
cachedCursor.value = {
|
||||
line: line.number - 1,
|
||||
ch: cursorPos - line.from,
|
||||
}
|
||||
|
||||
cursor.value = {
|
||||
line: cachedCursor.value.line,
|
||||
ch: cachedCursor.value.ch,
|
||||
}
|
||||
|
||||
if (update.docChanged) {
|
||||
// Expensive on big files ?
|
||||
cachedValue.value = update.state.doc
|
||||
.toJSON()
|
||||
.join(update.state.lineBreak)
|
||||
if (!options.extendedEditorConfig.readOnly)
|
||||
value.value = cachedValue.value
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (options.extendedEditorConfig.readOnly) {
|
||||
update.view.contentDOM.inputMode = "none"
|
||||
}
|
||||
}),
|
||||
EditorState.changeFilter.of(() => !options.extendedEditorConfig.readOnly),
|
||||
placeholderConfig.of(
|
||||
placeholder(options.extendedEditorConfig.placeholder ?? "")
|
||||
),
|
||||
language.of(
|
||||
getEditorLanguage(
|
||||
options.extendedEditorConfig.mode ?? "",
|
||||
options.linter ?? undefined,
|
||||
options.completer ?? undefined
|
||||
)
|
||||
),
|
||||
lineWrapping.of(
|
||||
options.extendedEditorConfig.lineWrapping
|
||||
? [EditorView.lineWrapping]
|
||||
: []
|
||||
),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
{
|
||||
key: "Tab",
|
||||
preventDefault: true,
|
||||
run: insertTab,
|
||||
},
|
||||
{
|
||||
key: "Shift-Tab",
|
||||
preventDefault: true,
|
||||
run: indentLess,
|
||||
},
|
||||
]),
|
||||
]
|
||||
|
||||
if (environmentTooltip) extensions.push(environmentTooltip.extension)
|
||||
|
||||
view.value = new EditorView({
|
||||
parent: el,
|
||||
state: EditorState.create({
|
||||
doc: value.value,
|
||||
extensions,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (el.value) {
|
||||
if (!view.value) initView(el.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(el, () => {
|
||||
if (el.value) {
|
||||
if (view.value) view.value.destroy()
|
||||
initView(el.value)
|
||||
} else {
|
||||
view.value?.destroy()
|
||||
view.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
view.value?.destroy()
|
||||
})
|
||||
|
||||
watch(value, (newVal) => {
|
||||
if (newVal === undefined) {
|
||||
view.value?.destroy()
|
||||
view.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (!view.value && el.value) {
|
||||
initView(el.value)
|
||||
}
|
||||
if (cachedValue.value !== newVal) {
|
||||
view.value?.dispatch({
|
||||
filter: false,
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.value.state.doc.length,
|
||||
insert: newVal,
|
||||
},
|
||||
})
|
||||
}
|
||||
cachedValue.value = newVal
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [
|
||||
options.extendedEditorConfig.mode,
|
||||
options.linter,
|
||||
options.completer,
|
||||
],
|
||||
() => {
|
||||
view.value?.dispatch({
|
||||
effects: language.reconfigure(
|
||||
getEditorLanguage(
|
||||
(options.extendedEditorConfig.mode as any) ?? "",
|
||||
options.linter ?? undefined,
|
||||
options.completer ?? undefined
|
||||
)
|
||||
),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => options.extendedEditorConfig.lineWrapping,
|
||||
(newMode) => {
|
||||
view.value?.dispatch({
|
||||
effects: lineWrapping.reconfigure(
|
||||
newMode ? [EditorView.lineWrapping] : []
|
||||
),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => options.extendedEditorConfig.placeholder,
|
||||
(newValue) => {
|
||||
view.value?.dispatch({
|
||||
effects: placeholderConfig.reconfigure(placeholder(newValue ?? "")),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(cursor, (newPos) => {
|
||||
if (view.value) {
|
||||
if (
|
||||
cachedCursor.value.line !== newPos.line ||
|
||||
cachedCursor.value.ch !== newPos.ch
|
||||
) {
|
||||
const line = view.value.state.doc.line(newPos.line + 1)
|
||||
const selUpdate = EditorSelection.cursor(line.from + newPos.ch - 1)
|
||||
|
||||
view.value?.focus()
|
||||
|
||||
view.value.dispatch({
|
||||
scrollIntoView: true,
|
||||
selection: selUpdate,
|
||||
effects: EditorView.scrollIntoView(selUpdate),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
216
packages/hoppscotch-common/src/composables/graphql.ts
Normal file
216
packages/hoppscotch-common/src/composables/graphql.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import * as E from "fp-ts/Either"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import {
|
||||
reactive,
|
||||
ref,
|
||||
Ref,
|
||||
unref,
|
||||
isRef,
|
||||
watchEffect,
|
||||
WatchStopHandle,
|
||||
watchSyncEffect,
|
||||
} from "vue"
|
||||
import {
|
||||
client,
|
||||
GQLError,
|
||||
parseGQLErrorString,
|
||||
} from "@helpers/backend/GQLClient"
|
||||
import {
|
||||
createRequest,
|
||||
GraphQLRequest,
|
||||
OperationResult,
|
||||
TypedDocumentNode,
|
||||
} from "@urql/core"
|
||||
import { Source, pipe as wonkaPipe, onEnd, subscribe } from "wonka"
|
||||
|
||||
type MaybeRef<X> = X | Ref<X>
|
||||
|
||||
type UseQueryOptions<T = any, V = object> = {
|
||||
query: TypedDocumentNode<T, V>
|
||||
variables?: MaybeRef<V>
|
||||
|
||||
updateSubs?: MaybeRef<GraphQLRequest<any, object>[]>
|
||||
defer?: boolean
|
||||
pollDuration?: number | undefined
|
||||
}
|
||||
|
||||
export const useGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
|
||||
_args: UseQueryOptions<DocType, DocVarType>
|
||||
) => {
|
||||
const stops: WatchStopHandle[] = []
|
||||
|
||||
const args = reactive(_args)
|
||||
|
||||
const loading: Ref<boolean> = ref(true)
|
||||
const isStale: Ref<boolean> = ref(true)
|
||||
const data: Ref<E.Either<GQLError<DocErrorType>, DocType>> = ref() as any
|
||||
|
||||
if (!args.updateSubs) args.updateSubs = []
|
||||
|
||||
const isPaused: Ref<boolean> = ref(args.defer ?? false)
|
||||
|
||||
const pollDuration: Ref<number | null> = ref(args.pollDuration ?? null)
|
||||
|
||||
const request: Ref<GraphQLRequest<DocType, DocVarType>> = ref(
|
||||
createRequest<DocType, DocVarType>(
|
||||
args.query,
|
||||
unref<DocVarType>(args.variables as any) as any
|
||||
)
|
||||
) as any
|
||||
|
||||
const source: Ref<Source<OperationResult> | undefined> = ref()
|
||||
|
||||
// A ref used to force re-execution of the query
|
||||
const updateTicker: Ref<boolean> = ref(true)
|
||||
|
||||
// Toggles between true and false to cause the polling operation to tick
|
||||
const pollerTick: Ref<boolean> = ref(true)
|
||||
|
||||
stops.push(
|
||||
watchEffect((onInvalidate) => {
|
||||
if (pollDuration.value !== null && !isPaused.value) {
|
||||
const handle = setInterval(() => {
|
||||
pollerTick.value = !pollerTick.value
|
||||
}, pollDuration.value)
|
||||
|
||||
onInvalidate(() => {
|
||||
clearInterval(handle)
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
stops.push(
|
||||
watchEffect(
|
||||
() => {
|
||||
const newRequest = createRequest<DocType, DocVarType>(
|
||||
args.query,
|
||||
unref<DocVarType>(args.variables as any) as any
|
||||
)
|
||||
|
||||
if (request.value.key !== newRequest.key) {
|
||||
request.value = newRequest
|
||||
}
|
||||
},
|
||||
{ flush: "pre" }
|
||||
)
|
||||
)
|
||||
|
||||
stops.push(
|
||||
watchEffect(
|
||||
() => {
|
||||
// Just listen to the polling ticks
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
pollerTick.value
|
||||
|
||||
// Just keep track of update ticking, but don't do anything
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
updateTicker.value
|
||||
|
||||
source.value = !isPaused.value
|
||||
? client.value.executeQuery<DocType, DocVarType>(request.value, {
|
||||
requestPolicy: "network-only",
|
||||
})
|
||||
: undefined
|
||||
},
|
||||
{ flush: "pre" }
|
||||
)
|
||||
)
|
||||
|
||||
watchSyncEffect((onInvalidate) => {
|
||||
if (source.value) {
|
||||
loading.value = true
|
||||
isStale.value = false
|
||||
|
||||
const invalidateStops = args.updateSubs!.map((sub) => {
|
||||
return wonkaPipe(
|
||||
client.value.executeSubscription(sub),
|
||||
onEnd(() => {
|
||||
if (source.value) execute()
|
||||
}),
|
||||
subscribe(() => {
|
||||
return execute()
|
||||
})
|
||||
).unsubscribe
|
||||
})
|
||||
|
||||
invalidateStops.push(
|
||||
wonkaPipe(
|
||||
source.value,
|
||||
onEnd(() => {
|
||||
loading.value = false
|
||||
isStale.value = false
|
||||
}),
|
||||
subscribe((res) => {
|
||||
if (res.operation.key === request.value.key) {
|
||||
data.value = pipe(
|
||||
// The target
|
||||
res.data as DocType | undefined,
|
||||
// Define what happens if data does not exist (it is an error)
|
||||
E.fromNullable(
|
||||
pipe(
|
||||
// Take the network error value
|
||||
res.error?.networkError,
|
||||
// If it null, set the left to the generic error name
|
||||
E.fromNullable(res.error?.message),
|
||||
E.match(
|
||||
// The left case (network error was null)
|
||||
(gqlErr) =>
|
||||
<GQLError<DocErrorType>>{
|
||||
type: "gql_error",
|
||||
error: parseGQLErrorString(
|
||||
gqlErr ?? ""
|
||||
) as DocErrorType,
|
||||
},
|
||||
// The right case (it was a GraphQL Error)
|
||||
(networkErr) =>
|
||||
<GQLError<DocErrorType>>{
|
||||
type: "network_error",
|
||||
error: networkErr,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
).unsubscribe
|
||||
)
|
||||
|
||||
onInvalidate(() => invalidateStops.forEach((unsub) => unsub()))
|
||||
}
|
||||
})
|
||||
|
||||
const execute = (updatedVars?: DocVarType) => {
|
||||
if (updatedVars) {
|
||||
if (isRef(args.variables)) {
|
||||
args.variables.value = updatedVars
|
||||
} else {
|
||||
args.variables = updatedVars
|
||||
}
|
||||
}
|
||||
|
||||
isPaused.value = false
|
||||
updateTicker.value = !updateTicker.value
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
isPaused.value = true
|
||||
}
|
||||
|
||||
const unpause = () => {
|
||||
isPaused.value = false
|
||||
}
|
||||
|
||||
const response = reactive({
|
||||
loading,
|
||||
data,
|
||||
pause,
|
||||
unpause,
|
||||
isStale,
|
||||
execute,
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
1
packages/hoppscotch-common/src/composables/head.ts
Normal file
1
packages/hoppscotch-common/src/composables/head.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useHead as usePageHead } from "@vueuse/head"
|
||||
6
packages/hoppscotch-common/src/composables/i18n.ts
Normal file
6
packages/hoppscotch-common/src/composables/i18n.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { flow } from "fp-ts/function"
|
||||
import { useI18n as _useI18n } from "vue-i18n"
|
||||
|
||||
export const useI18n = flow(_useI18n, (x) => x.t)
|
||||
|
||||
export const useFullI18n = _useI18n
|
||||
135
packages/hoppscotch-common/src/composables/lens-actions.ts
Normal file
135
packages/hoppscotch-common/src/composables/lens-actions.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { computed, ComputedRef, ref, Ref } from "vue"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as S from "fp-ts/string"
|
||||
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
|
||||
import { useToast } from "./toast"
|
||||
import { useI18n } from "./i18n"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { copyToClipboard } from "@helpers/utils/clipboard"
|
||||
import { HoppRESTResponse } from "@helpers/types/HoppRESTResponse"
|
||||
|
||||
export function useCopyResponse(responseBodyText: Ref<any>) {
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const copyIcon = refAutoReset(IconCopy, 1000)
|
||||
|
||||
const copyResponse = () => {
|
||||
copyToClipboard(responseBodyText.value)
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
return {
|
||||
copyIcon,
|
||||
copyResponse,
|
||||
}
|
||||
}
|
||||
|
||||
export type downloadResponseReturnType = (() => void) | Ref<any>
|
||||
|
||||
export function useDownloadResponse(
|
||||
contentType: string,
|
||||
responseBody: Ref<string | ArrayBuffer>
|
||||
) {
|
||||
const downloadIcon = refAutoReset(IconDownload, 1000)
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const downloadResponse = () => {
|
||||
const dataToWrite = responseBody.value
|
||||
const file = new Blob([dataToWrite], { type: contentType })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
// TODO: get uri from meta
|
||||
a.download = pipe(
|
||||
url,
|
||||
S.split("/"),
|
||||
RNEA.last,
|
||||
S.split("#"),
|
||||
RNEA.head,
|
||||
S.split("?"),
|
||||
RNEA.head
|
||||
)
|
||||
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
downloadIcon.value = IconCheck
|
||||
toast.success(`${t("state.download_started")}`)
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
}
|
||||
return {
|
||||
downloadIcon,
|
||||
downloadResponse,
|
||||
}
|
||||
}
|
||||
|
||||
export function usePreview(
|
||||
previewEnabledDefault: boolean,
|
||||
responseBodyText: Ref<string>
|
||||
): {
|
||||
previewFrame: any
|
||||
previewEnabled: Ref<boolean>
|
||||
togglePreview: () => void
|
||||
} {
|
||||
const previewFrame = ref<any | null>(null)
|
||||
const previewEnabled = ref(previewEnabledDefault)
|
||||
const url = ref("")
|
||||
|
||||
const togglePreview = () => {
|
||||
previewEnabled.value = !previewEnabled.value
|
||||
if (previewEnabled.value) {
|
||||
if (previewFrame.value.getAttribute("data-previewing-url") === url.value)
|
||||
return
|
||||
// Use DOMParser to parse document HTML.
|
||||
const previewDocument = new DOMParser().parseFromString(
|
||||
responseBodyText.value,
|
||||
"text/html"
|
||||
)
|
||||
// Inject <base href="..."> tag to head, to fix relative CSS/HTML paths.
|
||||
previewDocument.head.innerHTML =
|
||||
`<base href="${url.value}">` + previewDocument.head.innerHTML
|
||||
// Finally, set the iframe source to the resulting HTML.
|
||||
previewFrame.value.srcdoc = previewDocument.documentElement.outerHTML
|
||||
previewFrame.value.setAttribute("data-previewing-url", url.value)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previewFrame,
|
||||
previewEnabled,
|
||||
togglePreview,
|
||||
}
|
||||
}
|
||||
|
||||
export function useResponseBody(response: HoppRESTResponse): {
|
||||
responseBodyText: ComputedRef<string>
|
||||
} {
|
||||
const responseBodyText = computed(() => {
|
||||
if (
|
||||
response.type === "loading" ||
|
||||
response.type === "network_fail" ||
|
||||
response.type === "script_fail" ||
|
||||
response.type === "fail"
|
||||
)
|
||||
return ""
|
||||
if (typeof response.body === "string") return response.body
|
||||
else {
|
||||
const res = new TextDecoder("utf-8").decode(response.body)
|
||||
// HACK: Temporary trailing null character issue from the extension fix
|
||||
return res.replace(/\0+$/, "")
|
||||
}
|
||||
})
|
||||
return {
|
||||
responseBodyText,
|
||||
}
|
||||
}
|
||||
31
packages/hoppscotch-common/src/composables/poll.ts
Normal file
31
packages/hoppscotch-common/src/composables/poll.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { onBeforeUnmount, Ref, shallowRef } from "vue"
|
||||
|
||||
export function usePolled<T>(
|
||||
pollDurationMS: number,
|
||||
pollFunc: (stopPolling: () => void) => T
|
||||
): Ref<T> {
|
||||
let polling = true
|
||||
let handle: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const stopPolling = () => {
|
||||
if (handle) {
|
||||
clearInterval(handle)
|
||||
handle = undefined
|
||||
polling = false
|
||||
}
|
||||
}
|
||||
|
||||
const result = shallowRef(pollFunc(stopPolling))
|
||||
|
||||
if (polling) {
|
||||
handle = setInterval(() => {
|
||||
result.value = pollFunc(stopPolling)
|
||||
}, pollDurationMS)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (polling) stopPolling()
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
38
packages/hoppscotch-common/src/composables/pwa.ts
Normal file
38
packages/hoppscotch-common/src/composables/pwa.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { watch } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { pwaNeedsRefresh, refreshAppForPWAUpdate } from "@modules/pwa"
|
||||
|
||||
export const usePwaPrompt = function () {
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
watch(
|
||||
pwaNeedsRefresh,
|
||||
(value) => {
|
||||
if (value) {
|
||||
toast.show(`${t("app.new_version_found")}`, {
|
||||
duration: 0,
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.dismiss")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: `${t("app.reload")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
toastObject.goAway(0)
|
||||
refreshAppForPWAUpdate()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
33
packages/hoppscotch-common/src/composables/ref.ts
Normal file
33
packages/hoppscotch-common/src/composables/ref.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { customRef, onBeforeUnmount, Ref, watch } from "vue"
|
||||
|
||||
export function pluckRef<T, K extends keyof T>(ref: Ref<T>, key: K): Ref<T[K]> {
|
||||
return customRef((track, trigger) => {
|
||||
const stopWatching = watch(ref, (newVal, oldVal) => {
|
||||
if (newVal[key] !== oldVal[key]) {
|
||||
trigger()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopWatching()
|
||||
})
|
||||
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return ref.value[key]
|
||||
},
|
||||
set(value: T[K]) {
|
||||
trigger()
|
||||
ref.value = Object.assign(ref.value, { [key]: value })
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function pluckMultipleFromRef<T, K extends Array<keyof T>>(
|
||||
sourceRef: Ref<T>,
|
||||
keys: K
|
||||
): { [key in K[number]]: Ref<T[key]> } {
|
||||
return Object.fromEntries(keys.map((x) => [x, pluckRef(sourceRef, x)])) as any
|
||||
}
|
||||
44
packages/hoppscotch-common/src/composables/settings.ts
Normal file
44
packages/hoppscotch-common/src/composables/settings.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Ref } from "vue"
|
||||
import { settingsStore, SettingsType } from "~/newstore/settings"
|
||||
import { pluck, distinctUntilChanged } from "rxjs/operators"
|
||||
import { useStream, useStreamStatic } from "./stream"
|
||||
|
||||
export function useSetting<K extends keyof SettingsType>(
|
||||
settingKey: K
|
||||
): Ref<SettingsType[K]> {
|
||||
return useStream(
|
||||
settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged()),
|
||||
settingsStore.value[settingKey],
|
||||
(value: SettingsType[K]) => {
|
||||
settingsStore.dispatch({
|
||||
dispatcher: "applySetting",
|
||||
payload: {
|
||||
settingKey,
|
||||
value,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A static version (does not require component setup)
|
||||
* of `useSetting`
|
||||
*/
|
||||
export function useSettingStatic<K extends keyof SettingsType>(
|
||||
settingKey: K
|
||||
): [Ref<SettingsType[K]>, () => void] {
|
||||
return useStreamStatic(
|
||||
settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged()),
|
||||
settingsStore.value[settingKey],
|
||||
(value: SettingsType[K]) => {
|
||||
settingsStore.dispatch({
|
||||
dispatcher: "applySetting",
|
||||
payload: {
|
||||
settingKey,
|
||||
value,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
174
packages/hoppscotch-common/src/composables/stream.ts
Normal file
174
packages/hoppscotch-common/src/composables/stream.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { clone, cloneDeep } from "lodash-es"
|
||||
import { Observable, Subscription } from "rxjs"
|
||||
import { customRef, onBeforeUnmount, readonly, Ref } from "vue"
|
||||
|
||||
type CloneMode = "noclone" | "shallow" | "deep"
|
||||
|
||||
/**
|
||||
* Returns a readonly (no writes) ref for an RxJS Observable
|
||||
* @param stream$ The RxJS Observable to listen to
|
||||
* @param initialValue The initial value to apply until the stream emits a value
|
||||
* @param cloneMode Determines whether or not and how deep to clone the emitted value.
|
||||
* Useful for issues in reactivity due to reference sharing. Defaults to shallow clone
|
||||
* @returns A readonly ref which has the latest value from the stream
|
||||
*/
|
||||
export function useReadonlyStream<T>(
|
||||
stream$: Observable<T>,
|
||||
initialValue: T,
|
||||
cloneMode: CloneMode = "shallow"
|
||||
): Ref<T> {
|
||||
let sub: Subscription | null = null
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (sub) {
|
||||
sub.unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
const r = customRef((track, trigger) => {
|
||||
let val = initialValue
|
||||
|
||||
sub = stream$.subscribe((value) => {
|
||||
if (cloneMode === "noclone") {
|
||||
val = value
|
||||
} else if (cloneMode === "shallow") {
|
||||
val = clone(value)
|
||||
} else if (cloneMode === "deep") {
|
||||
val = cloneDeep(value)
|
||||
}
|
||||
|
||||
trigger()
|
||||
})
|
||||
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return val
|
||||
},
|
||||
set() {
|
||||
trigger() // <- Not exactly needed here
|
||||
throw new Error("Cannot write to a ref from useReadonlyStream")
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Casting to still maintain the proper type signature for ease of use
|
||||
return readonly(r) as Ref<T>
|
||||
}
|
||||
|
||||
export function useStream<T>(
|
||||
stream$: Observable<T>,
|
||||
initialValue: T,
|
||||
setter: (val: T) => void
|
||||
) {
|
||||
let sub: Subscription | null = null
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (sub) {
|
||||
sub.unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
return customRef((track, trigger) => {
|
||||
let value = initialValue
|
||||
|
||||
sub = stream$.subscribe((val) => {
|
||||
value = val
|
||||
trigger()
|
||||
})
|
||||
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return value
|
||||
},
|
||||
set(value: T) {
|
||||
trigger()
|
||||
setter(value)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** A static (doesn't cleanup on itself and does
|
||||
* not require component instace) version of useStream
|
||||
*/
|
||||
export function useStreamStatic<T>(
|
||||
stream$: Observable<T>,
|
||||
initialValue: T,
|
||||
setter: (val: T) => void
|
||||
): [Ref<T>, () => void] {
|
||||
let sub: Subscription | null = null
|
||||
|
||||
const stopper = () => {
|
||||
if (sub) {
|
||||
sub.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
customRef((track, trigger) => {
|
||||
let value = initialValue
|
||||
|
||||
sub = stream$.subscribe((val) => {
|
||||
value = val
|
||||
trigger()
|
||||
})
|
||||
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return value
|
||||
},
|
||||
set(value: T) {
|
||||
trigger()
|
||||
setter(value)
|
||||
},
|
||||
}
|
||||
}),
|
||||
stopper,
|
||||
]
|
||||
}
|
||||
|
||||
export type StreamSubscriberFunc = <T>(
|
||||
stream: Observable<T>,
|
||||
next?: ((value: T) => void) | undefined,
|
||||
error?: ((e: any) => void) | undefined,
|
||||
complete?: (() => void) | undefined
|
||||
) => void
|
||||
|
||||
/**
|
||||
* A composable that provides the ability to run streams
|
||||
* and subscribe to them and respect the component lifecycle.
|
||||
*/
|
||||
export function useStreamSubscriber(): {
|
||||
subscribeToStream: StreamSubscriberFunc
|
||||
} {
|
||||
const subs: Subscription[] = []
|
||||
|
||||
const runAndSubscribe = <T>(
|
||||
stream: Observable<T>,
|
||||
next?: (value: T) => void,
|
||||
error?: (e: any) => void,
|
||||
complete?: () => void
|
||||
) => {
|
||||
const sub = stream.subscribe({
|
||||
next,
|
||||
error,
|
||||
complete: () => {
|
||||
if (complete) complete()
|
||||
subs.splice(subs.indexOf(sub), 1)
|
||||
},
|
||||
})
|
||||
|
||||
subs.push(sub)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subs.forEach((sub) => sub.unsubscribe())
|
||||
})
|
||||
|
||||
return {
|
||||
subscribeToStream: runAndSubscribe,
|
||||
}
|
||||
}
|
||||
4
packages/hoppscotch-common/src/composables/theming.ts
Normal file
4
packages/hoppscotch-common/src/composables/theming.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { inject } from "vue"
|
||||
import { HoppColorMode } from "~/modules/theming"
|
||||
|
||||
export const useColorMode = () => inject("colorMode") as HoppColorMode
|
||||
3
packages/hoppscotch-common/src/composables/toast.ts
Normal file
3
packages/hoppscotch-common/src/composables/toast.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { useToasted } from "@hoppscotch/vue-toasted"
|
||||
|
||||
export const useToast = useToasted
|
||||
Reference in New Issue
Block a user