mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
34 Commits
feat/fully
...
v3.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77916019cd | ||
|
|
02aa669578 | ||
|
|
d8809e09e7 | ||
|
|
04fdd6f8a9 | ||
|
|
3097f3aa76 | ||
|
|
9c3eabdda2 | ||
|
|
7c4da8b8bc | ||
|
|
1e4c3464d2 | ||
|
|
5695f69430 | ||
|
|
d0bbac6b66 | ||
|
|
51e2c045ec | ||
|
|
b585c3e943 | ||
|
|
8150a21395 | ||
|
|
0470e8d1a7 | ||
|
|
031b373bac | ||
|
|
586fd6b7f6 | ||
|
|
51765da0b1 | ||
|
|
5b02aad92a | ||
|
|
606d03180f | ||
|
|
a86551ad27 | ||
|
|
60f8611dd7 | ||
|
|
09be7131cc | ||
|
|
d45a975335 | ||
|
|
6f82eae80f | ||
|
|
ab2326deb3 | ||
|
|
5a4d337ed3 | ||
|
|
cc197e0c30 | ||
|
|
9fa6acca4e | ||
|
|
da892243d2 | ||
|
|
994b60678e | ||
|
|
e001b6ba51 | ||
|
|
59453536a6 | ||
|
|
7fc4ff274d | ||
|
|
663ece708e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,6 +51,9 @@ bruno.iml
|
||||
.cursor
|
||||
.claude
|
||||
.codex
|
||||
.agents
|
||||
.agent
|
||||
skills-lock.json
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error'
|
||||
'no-undef': 'error',
|
||||
'no-case-declarations': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
9153
package-lock.json
generated
9153
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
|
||||
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
|
||||
'^.+\\.[jt]sx?$': 'babel-jest'
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
const babelJest = require('babel-jest')
|
||||
|
||||
module.exports = {
|
||||
process(sourceText, sourcePath, options) {
|
||||
const transformer = babelJest.createTransformer();
|
||||
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
|
||||
}
|
||||
};
|
||||
@@ -39,7 +39,7 @@
|
||||
"github-markdown-css": "^5.2.0",
|
||||
"graphiql": "3.7.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^3.7.0",
|
||||
"graphql-request": "4.2.0",
|
||||
"hexy": "^0.3.5",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
@@ -102,7 +102,7 @@
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@rsbuild/core": "^1.1.2",
|
||||
"@rsbuild/plugin-babel": "^1.0.3",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.2.0",
|
||||
"@rsbuild/plugin-node-polyfill": "1.2.0",
|
||||
"@rsbuild/plugin-react": "^1.0.7",
|
||||
"@rsbuild/plugin-sass": "^1.1.0",
|
||||
"@rsbuild/plugin-styled-components": "1.1.0",
|
||||
|
||||
@@ -160,7 +160,6 @@ const AppTitleBar = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -221,9 +217,6 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(editor, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,12 +288,6 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this.editor);
|
||||
|
||||
@@ -99,24 +99,6 @@ const Wrapper = styled.div`
|
||||
.name-cell-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.name-highlight-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
background: ${(props) => props.theme.colors.accent}55;
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
|
||||
@@ -31,15 +31,6 @@ const TableRow = React.memo(
|
||||
}
|
||||
);
|
||||
|
||||
const highlightText = (text, query) => {
|
||||
if (!query?.trim() || !text) return text;
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentVariablesTable = ({
|
||||
environment,
|
||||
collection,
|
||||
@@ -51,8 +42,7 @@ const EnvironmentVariablesTable = ({
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
}) => {
|
||||
const { storedTheme, theme } = useTheme();
|
||||
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
@@ -60,7 +50,7 @@ const EnvironmentVariablesTable = ({
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
|
||||
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -103,6 +93,13 @@ const EnvironmentVariablesTable = ({
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
const handleRowFocus = useCallback((uid) => {
|
||||
setPinnedData((prev) => ({
|
||||
query: searchQuery,
|
||||
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
|
||||
}));
|
||||
}, [searchQuery]);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
@@ -205,6 +202,10 @@ const EnvironmentVariablesTable = ({
|
||||
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
|
||||
}, [environment.variables]);
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedData({ query: '', uids: new Set() });
|
||||
}, [savedValuesJson]);
|
||||
|
||||
// Sync modified state
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
@@ -358,6 +359,7 @@ const EnvironmentVariablesTable = ({
|
||||
onSave(cloneDeep(variablesToSave))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
onDraftClear();
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
@@ -376,7 +378,7 @@ const EnvironmentVariablesTable = ({
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
}, [formik.values, environment.variables, onSave, setIsModified]);
|
||||
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const originalVars = environment.variables || [];
|
||||
@@ -418,12 +420,20 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
|
||||
return allVariables.filter(({ variable }) => {
|
||||
if (effectivePins.has(variable.uid)) return true;
|
||||
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
|
||||
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
|
||||
const valueText
|
||||
= typeof variable.value === 'string'
|
||||
? variable.value
|
||||
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
|
||||
? String(variable.value)
|
||||
: '';
|
||||
const valueMatch = valueText.toLowerCase().includes(query);
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery]);
|
||||
}, [formik.values, searchQuery, pinnedData]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
@@ -460,11 +470,6 @@ const EnvironmentVariablesTable = ({
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
const activeQuery = searchQuery?.trim().toLowerCase();
|
||||
const valueMatchesOnly = activeQuery
|
||||
&& !(variable.name?.toLowerCase().includes(activeQuery))
|
||||
&& typeof variable.value === 'string'
|
||||
&& variable.value.toLowerCase().includes(activeQuery);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -475,8 +480,7 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
@@ -494,29 +498,25 @@ const EnvironmentVariablesTable = ({
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
readOnly={isSearchActive}
|
||||
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
onBlur={() => {
|
||||
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
|
||||
handleNameBlur(actualIndex);
|
||||
}}
|
||||
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
|
||||
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
|
||||
<div className="name-highlight-overlay">
|
||||
{highlightText(variable.name || '', searchQuery)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<div
|
||||
className="overflow-hidden grow w-full relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
@@ -524,7 +524,7 @@ const EnvironmentVariablesTable = ({
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={isSearchActive || typeof variable.value !== 'string'}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
@@ -555,14 +555,13 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import useDeferredLoading from 'hooks/useDeferredLoading';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DotEnvTableView from './DotEnvTableView';
|
||||
@@ -31,6 +32,7 @@ const DotEnvFileEditor = ({
|
||||
const [rawValue, setRawValue] = useState(initialRawValue);
|
||||
const [prevViewMode, setPrevViewMode] = useState(viewMode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const showSaving = useDeferredLoading(isSaving, 200);
|
||||
|
||||
const formikRef = useRef(null);
|
||||
|
||||
@@ -311,7 +313,7 @@ const DotEnvFileEditor = ({
|
||||
onChange={handleRawChange}
|
||||
onSave={handleSaveRaw}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -335,7 +337,7 @@ const DotEnvFileEditor = ({
|
||||
onRemoveVar={handleRemoveVar}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 8px 20px;
|
||||
padding: 9px 20px 8px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
|
||||
@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
@@ -110,7 +110,15 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-inline: 4px !important;
|
||||
padding-left: 6px !important;
|
||||
border-radius: 6px ;
|
||||
padding-right: 3px !important;
|
||||
padding-block: 4px !important;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
@@ -153,7 +161,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
|
||||
@@ -49,7 +49,6 @@ const EnvironmentList = ({
|
||||
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
@@ -84,6 +83,8 @@ const EnvironmentList = ({
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
const environmentsDraftUid = collection?.environmentsDraft?.environmentUid;
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
@@ -92,10 +93,10 @@ const EnvironmentList = ({
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile]);
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
@@ -558,51 +559,65 @@ const EnvironmentList = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleCreateEnvClick();
|
||||
}}
|
||||
title="Search environments"
|
||||
title="Create environment"
|
||||
>
|
||||
<IconSearch size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleImportClick();
|
||||
}}
|
||||
title="Import environment"
|
||||
>
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleExportClick();
|
||||
}}
|
||||
title="Export environment"
|
||||
>
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{isEnvListSearchExpanded && (
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
className="env-list-search-clear"
|
||||
title="Clear search"
|
||||
onClick={() => setSearchText('')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
|
||||
@@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => {
|
||||
.test('unique-name', 'A workspace with this name already exists', function (value) {
|
||||
if (!value) return true;
|
||||
return !workspaces.some((w) =>
|
||||
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
|
||||
w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -27,7 +27,8 @@ const ManageWorkspace = () => {
|
||||
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
|
||||
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
const persistedWorkspaces = workspaces.filter((w) => !w.isCreating);
|
||||
return sortWorkspaces(persistedWorkspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -69,7 +70,6 @@ const ManageWorkspace = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import React from 'react';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
const markdownItOptions = {
|
||||
@@ -33,14 +35,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
};
|
||||
|
||||
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
|
||||
|
||||
const htmlFromMarkdown = md.render(content || '');
|
||||
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
|
||||
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
className="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
|
||||
dangerouslySetInnerHTML={{ __html: cleanHTML }}
|
||||
onClick={handleOnClick}
|
||||
onDoubleClick={handleOnDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -49,16 +45,16 @@ class MultiLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
// 'Ctrl-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
// 'Cmd-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
@@ -94,9 +90,6 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
@@ -171,6 +164,10 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
@@ -186,12 +183,6 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { IconLoader2, IconCloud } from '@tabler/icons';
|
||||
import fastJsonFormat from 'fast-json-format';
|
||||
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
|
||||
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
|
||||
|
||||
/**
|
||||
* Pretty-print JSON content for readable display. YAML content is returned as-is.
|
||||
*/
|
||||
const prettyPrintSpec = (content) => {
|
||||
if (!content) return content;
|
||||
if (content.trimStart()[0] !== '{') return content;
|
||||
try {
|
||||
return fastJsonFormat(content);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
const OpenAPISpecTab = ({ collection }) => {
|
||||
const [specContent, setSpecContent] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -19,8 +33,7 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
if (result.error) {
|
||||
// Local file not found — fall back to fetching from remote URL
|
||||
@@ -37,14 +50,14 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
}
|
||||
});
|
||||
if (fetchResult.content) {
|
||||
setSpecContent(fetchResult.content);
|
||||
setSpecContent(prettyPrintSpec(fetchResult.content));
|
||||
setIsRemote(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(result.error);
|
||||
} else {
|
||||
setSpecContent(result.content);
|
||||
setSpecContent(prettyPrintSpec(result.content));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to read spec file');
|
||||
|
||||
@@ -5,15 +5,15 @@ import {
|
||||
IconTrash,
|
||||
IconArrowBackUp,
|
||||
IconExternalLink,
|
||||
IconClock,
|
||||
IconInfoCircle
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import moment from 'moment';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Modal from 'components/Modal';
|
||||
import EndpointChangeSection from '../EndpointChangeSection';
|
||||
import EndpointItem from '../EndpointChangeSection/EndpointItem';
|
||||
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
|
||||
import useEndpointActions from '../hooks/useEndpointActions';
|
||||
|
||||
@@ -24,7 +24,9 @@ const CollectionStatusSection = ({
|
||||
specDrift,
|
||||
storedSpec,
|
||||
lastSyncDate,
|
||||
onOpenEndpoint
|
||||
onOpenEndpoint,
|
||||
isLoading,
|
||||
onTabSelect
|
||||
}) => {
|
||||
const {
|
||||
pendingAction, setPendingAction,
|
||||
@@ -39,7 +41,8 @@ const CollectionStatusSection = ({
|
||||
} = useEndpointActions(collection, collectionDrift, reloadDrift);
|
||||
|
||||
const spec = storedSpec || specDrift?.newSpec;
|
||||
const hasDrift = !!collectionDrift && (collectionDrift.modified?.length > 0
|
||||
const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0
|
||||
|| collectionDrift.missing?.length > 0
|
||||
|| collectionDrift.localOnly?.length > 0);
|
||||
|
||||
@@ -85,12 +88,6 @@ const CollectionStatusSection = ({
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">
|
||||
{bannerState.message}
|
||||
{bannerState.version && (
|
||||
<> · <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
|
||||
)}
|
||||
{bannerState.lastSyncDate && (
|
||||
<span className="checked-text"> · Synced {moment(bannerState.lastSyncDate).fromNow()}</span>
|
||||
)}
|
||||
</span>
|
||||
{bannerState.badges && (
|
||||
<span className="banner-details">
|
||||
@@ -113,7 +110,7 @@ const CollectionStatusSection = ({
|
||||
{hasDrift && (
|
||||
<div className="sync-info-notice mt-4">
|
||||
<IconInfoCircle size={14} className="sync-info-icon" />
|
||||
<span><span className="whats-updated-title">What's tracked:</span> Changes to URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
|
||||
<span><span className="whats-updated-title">What's tracked:</span> Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -211,11 +208,27 @@ const CollectionStatusSection = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
|
||||
<h4>Checking for updates</h4>
|
||||
<p>Comparing your collection with the last synced spec...</p>
|
||||
</div>
|
||||
) : !hasStoredSpec ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconAlertTriangle size={40} className="empty-state-icon" />
|
||||
<h4>{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}</h4>
|
||||
<p>{lastSyncDate
|
||||
? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.'
|
||||
: 'Once you sync your collection with the spec, local changes will appear here.'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => onTabSelect('spec-updates')}>Go to Spec Updates</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No changes in collection</h4>
|
||||
<p>The collection matches the last synced spec. Nothing to review.</p>
|
||||
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Action confirmation modal */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
|
||||
|
||||
@@ -77,7 +77,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
setError('The selected file is not a valid OpenAPI specification');
|
||||
setError('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
const filePath = window.ipcRenderer.getFilePath(file);
|
||||
@@ -100,7 +100,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={mode === 'url' ? !isValidUrl(sourceUrl.trim()) : !sourceUrl.trim()}
|
||||
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
|
||||
loading={isLoading}
|
||||
>
|
||||
Connect
|
||||
@@ -124,6 +124,17 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,17 +2,18 @@ import { useState, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'components/Modal';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
|
||||
|
||||
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const isUrl = isValidUrl(sourceUrl);
|
||||
const normalizedSourceUrl = (sourceUrl || '').trim();
|
||||
const isUrl = isHttpUrl(normalizedSourceUrl);
|
||||
const initialMode = isUrl ? 'url' : 'file';
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [url, setUrl] = useState(isUrl ? (sourceUrl || '') : '');
|
||||
const [filePath, setFilePath] = useState(isUrl ? '' : sourceUrl);
|
||||
const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : '');
|
||||
const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl);
|
||||
const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false);
|
||||
const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -21,7 +22,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
|
||||
const intervals = [5, 15, 30, 60];
|
||||
|
||||
const effectiveSource = mode === 'file' ? filePath : url.trim();
|
||||
const canSave = mode === 'file' ? !!effectiveSource : isValidUrl(effectiveSource.trim());
|
||||
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
@@ -84,7 +85,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
toast.error('The selected file is not a valid OpenAPI specification');
|
||||
toast.error('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
const path = window.ipcRenderer.getFilePath(file);
|
||||
|
||||
@@ -15,7 +15,7 @@ const DisconnectSyncModal = ({ onConfirm, onClose }) => {
|
||||
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
|
||||
</p>
|
||||
<div className="disconnect-actions">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="ghost" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="danger" onClick={onConfirm}>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
@@ -9,7 +12,6 @@ import {
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Help from 'components/Help';
|
||||
@@ -23,8 +25,20 @@ const OpenAPISyncHeader = ({
|
||||
const sourceIsLocal = !isHttpUrl(sourceUrl);
|
||||
const canCheck = !!sourceUrl?.trim();
|
||||
|
||||
const title = spec?.info?.title || 'Unknown API';
|
||||
const version = spec?.info?.version || '-';
|
||||
// Resolve relative file paths to absolute for display
|
||||
const [displayPath, setDisplayPath] = useState(sourceUrl);
|
||||
useEffect(() => {
|
||||
if (sourceIsLocal && sourceUrl) {
|
||||
window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname)
|
||||
.then((resolved) => setDisplayPath(resolved))
|
||||
.catch(() => setDisplayPath(sourceUrl));
|
||||
} else {
|
||||
setDisplayPath(sourceUrl);
|
||||
}
|
||||
}, [sourceUrl, sourceIsLocal, collection.pathname]);
|
||||
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!sourceUrl) return;
|
||||
@@ -111,7 +125,7 @@ const OpenAPISyncHeader = ({
|
||||
type="button"
|
||||
onClick={revealInFolder}
|
||||
>
|
||||
{sourceUrl}
|
||||
{displayPath}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { countEndpoints } from '../utils';
|
||||
import moment from 'moment';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Help from 'components/Help';
|
||||
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
||||
|
||||
const countEndpoints = (spec) => {
|
||||
if (!spec?.paths) return null;
|
||||
let count = 0;
|
||||
for (const path of Object.values(spec.paths)) {
|
||||
for (const key of Object.keys(path)) {
|
||||
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
|
||||
|
||||
const SUMMARY_CARDS = [
|
||||
@@ -32,7 +21,7 @@ const SUMMARY_CARDS = [
|
||||
key: 'inSync',
|
||||
label: 'In Sync with Spec',
|
||||
color: 'green',
|
||||
tooltip: 'Endpoints that currently match the latest spec'
|
||||
tooltip: 'Endpoints that currently match the latest spec from the source'
|
||||
},
|
||||
{
|
||||
key: 'changed',
|
||||
@@ -50,27 +39,26 @@ const SUMMARY_CARDS = [
|
||||
}
|
||||
];
|
||||
|
||||
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, fileNotFound, onOpenSettings }) => {
|
||||
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const activeError = error || reduxError;
|
||||
|
||||
const version = storedSpec?.info?.version;
|
||||
const endpointCount = countEndpoints(storedSpec);
|
||||
const version = specMeta?.version;
|
||||
const endpointCount = specMeta?.endpointCount ?? null;
|
||||
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
|
||||
const groupBy = openApiSyncConfig?.groupBy || 'tags';
|
||||
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
|
||||
const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5;
|
||||
|
||||
// Endpoint Summary counts
|
||||
// Total/In Sync: always compare against remote spec
|
||||
// Total: from collection items in Redux; In Sync: from remote spec comparison
|
||||
// Changed/Conflicts: compare against stored spec in AppData (0 on initial sync)
|
||||
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
|
||||
const totalInCollection = remoteDrift
|
||||
? (remoteDrift.inSync?.length || 0) + (remoteDrift.modified?.length || 0) + (remoteDrift.localOnly?.length || 0)
|
||||
: null;
|
||||
const totalInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const inSyncCount = remoteDrift
|
||||
? (remoteDrift.inSync?.length || 0)
|
||||
@@ -111,6 +99,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
const hasSpecUpdates = specUpdatesPending > 0;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
|
||||
if (activeError) {
|
||||
return {
|
||||
variant: 'danger',
|
||||
@@ -127,19 +119,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
buttons: ['review']
|
||||
};
|
||||
}
|
||||
if (specDrift?.storedSpecMissing && lastSyncDate) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'Last synced spec not found',
|
||||
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes..',
|
||||
buttons: ['restore']
|
||||
};
|
||||
}
|
||||
if (!hasDriftData) return null;
|
||||
if (hasSpecUpdates && hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'The API spec has new updates and the collection has changes',
|
||||
title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`,
|
||||
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
|
||||
buttons: ['sync', 'changes']
|
||||
};
|
||||
@@ -147,11 +130,20 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
if (hasSpecUpdates) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'The API spec has new updates',
|
||||
title: `OpenAPI spec has new updates${versionInfo}`,
|
||||
subtitle: 'New or changed requests are available.',
|
||||
buttons: ['sync']
|
||||
};
|
||||
}
|
||||
if (specDrift?.storedSpecMissing && lastSyncDate) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'Last synced spec not found',
|
||||
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.',
|
||||
buttons: ['spec-details']
|
||||
};
|
||||
}
|
||||
if (!hasDriftData) return null;
|
||||
if (hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'muted',
|
||||
@@ -160,14 +152,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
buttons: ['changes']
|
||||
};
|
||||
}
|
||||
// return {
|
||||
// variant: 'success',
|
||||
// title: 'Collection is in sync with the spec',
|
||||
// subtitle: null,
|
||||
// buttons: []
|
||||
// };
|
||||
return null;
|
||||
}, [activeError, fileNotFound, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, lastSyncDate]);
|
||||
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
|
||||
|
||||
return (
|
||||
<div className="overview-section">
|
||||
@@ -179,12 +165,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">{bannerState.title}</span>
|
||||
{bannerState.showBadge && (
|
||||
<StatusBadge status="info" radius="full">{specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'}</StatusBadge>
|
||||
)}
|
||||
{bannerState.showChangesBadge && (
|
||||
<StatusBadge status="warning" radius="full">{changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
{bannerState.subtitle && (
|
||||
<p className="banner-subtitle">{bannerState.subtitle}</p>
|
||||
@@ -207,9 +187,9 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
Review and Sync Collection
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('restore') && (
|
||||
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Restore Spec File
|
||||
{bannerState.buttons.includes('spec-details') && (
|
||||
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Go to Spec Updates
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('open-settings') && (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
|
||||
const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
const diffRef = useRef(null);
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isRendering, setIsRendering] = useState(true);
|
||||
|
||||
const addedCount = specDrift?.added?.length || 0;
|
||||
const modifiedCount = specDrift?.modified?.length || 0;
|
||||
@@ -17,7 +19,11 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const { Diff2Html } = window;
|
||||
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) return;
|
||||
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
|
||||
setIsRendering(false);
|
||||
return;
|
||||
}
|
||||
setIsRendering(true);
|
||||
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
|
||||
drawFileList: false,
|
||||
matching: 'lines',
|
||||
@@ -29,6 +35,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
});
|
||||
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
|
||||
diffRef.current.innerHTML = diffHtml;
|
||||
setIsRendering(false);
|
||||
}, [displayedTheme, specDrift?.unifiedDiff]);
|
||||
|
||||
return (
|
||||
@@ -40,8 +47,8 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
>
|
||||
<div className="spec-diff-modal">
|
||||
<div className="spec-diff-badges">
|
||||
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
|
||||
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
|
||||
{modifiedCount > 0 && <StatusBadge status="info">Updated: {modifiedCount}</StatusBadge>}
|
||||
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
|
||||
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
|
||||
</div>
|
||||
@@ -60,7 +67,13 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
|
||||
<span className="diff-column-label">Updated Spec</span>
|
||||
</div>
|
||||
<div ref={diffRef}></div>
|
||||
{isRendering && (
|
||||
<div className="text-diff-loading">
|
||||
<IconLoader2 className="animate-spin" size={20} strokeWidth={1.5} />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-diff-empty">No text diff available.</div>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconRefresh
|
||||
IconRefresh,
|
||||
IconAlertTriangle,
|
||||
IconClock
|
||||
} from '@tabler/icons';
|
||||
import moment from 'moment';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
@@ -23,41 +24,37 @@ const SpecStatusSection = ({
|
||||
|
||||
const {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleSyncNow, handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
} = useSyncFlow({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
sourceUrl, setError, checkForUpdates: onCheck
|
||||
setError, checkForUpdates: onCheck
|
||||
});
|
||||
|
||||
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
|
||||
|
||||
const hasRemoteUpdates = remoteDrift && (
|
||||
(remoteDrift.missing?.length || 0)
|
||||
+ (remoteDrift.modified?.length || 0)
|
||||
+ (remoteDrift.localOnly?.length || 0)
|
||||
) > 0;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
if (fileNotFound) {
|
||||
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
|
||||
}
|
||||
if (error || specDrift?.isValid === false) {
|
||||
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: [] };
|
||||
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
|
||||
}
|
||||
if (!specDrift) {
|
||||
return null;
|
||||
// TODO: re-enable success banner
|
||||
// if (!lastSyncedAt) return null;
|
||||
// return {
|
||||
// variant: 'success', message: 'Spec is up to date', actions: [],
|
||||
// version: storedSpec?.info?.version,
|
||||
// lastChecked: moment(lastCheckedAt || lastSyncedAt).fromNow()
|
||||
// };
|
||||
}
|
||||
if (specDrift.storedSpecMissing) {
|
||||
if (!lastSyncedAt) {
|
||||
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
}
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
|
||||
return null;
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
const hasEndpointUpdates = specDrift.storedSpecMissing
|
||||
? hasRemoteUpdates
|
||||
: (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
|
||||
if (hasEndpointUpdates) {
|
||||
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
@@ -66,13 +63,8 @@ const SpecStatusSection = ({
|
||||
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
|
||||
};
|
||||
}
|
||||
// return {
|
||||
// variant: 'success', message: 'Spec is up to date', actions: [],
|
||||
// version: specDrift.newVersion || storedSpec?.info?.version || specDrift.storedVersion,
|
||||
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
|
||||
// };
|
||||
return null;
|
||||
}, [isLoading, fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
|
||||
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
|
||||
return (
|
||||
<>
|
||||
{bannerState && (
|
||||
@@ -94,16 +86,13 @@ const SpecStatusSection = ({
|
||||
</span>
|
||||
{bannerState.changes && (
|
||||
<span className="banner-details">
|
||||
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
|
||||
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
|
||||
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="info" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
|
||||
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
{bannerState.actions.includes('quick-sync') && (
|
||||
<Button size="xs" onClick={handleSyncNow}>Restore Spec File</Button>
|
||||
)}
|
||||
{bannerState.actions.includes('open-settings') && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
@@ -114,16 +103,22 @@ const SpecStatusSection = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
|
||||
{(error || fileNotFound || specDrift?.isValid === false) ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconRefresh size={40} className="empty-state-icon" />
|
||||
<h4>Last Synced Spec not found in storage</h4>
|
||||
<p>The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.</p>
|
||||
<Button className="mt-4" color="warning" onClick={handleSyncNow} loading={isSyncing}>
|
||||
<IconAlertTriangle size={40} className="empty-state-icon" />
|
||||
<h4>Unable to check for updates</h4>
|
||||
<p>Fix the connection issue above and check again.</p>
|
||||
</div>
|
||||
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The spec endpoints have not been updated since the last sync. You can restore the spec file to track local collection changes.</p>
|
||||
<Button className="mt-4" color="warning" onClick={handleRestoreSpec} loading={isSyncing}>
|
||||
Restore Spec File
|
||||
</Button>
|
||||
</div>
|
||||
) : remoteDrift && (
|
||||
) : (
|
||||
<div className="mt-5">
|
||||
<SyncReviewPage
|
||||
specDrift={specDrift}
|
||||
@@ -133,6 +128,7 @@ const SpecStatusSection = ({
|
||||
collectionUid={collection.uid}
|
||||
newSpec={specDrift?.newSpec}
|
||||
isSyncing={isSyncing}
|
||||
isLoading={isLoading}
|
||||
onApplySync={handleApplySync}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -625,7 +625,7 @@ const StyledWrapper = styled.div`
|
||||
.settings-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
color: ${(props) => props.theme.text};
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
@@ -670,7 +670,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.toggle-description {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@ const StyledWrapper = styled.div`
|
||||
&.type-local-only { background: ${(props) => props.theme.colors.text.muted}; }
|
||||
&.type-in-sync { background: ${(props) => props.theme.colors.text.green}; }
|
||||
&.type-conflict { background: ${(props) => props.theme.colors.text.danger}; }
|
||||
&.type-spec-modified { background: ${(props) => props.theme.colors.text.info}; }
|
||||
&.type-spec-modified { background: ${(props) => props.theme.colors.text.warning}; }
|
||||
&.type-collection-drift { background: ${(props) => props.theme.colors.text.warning}; }
|
||||
}
|
||||
|
||||
@@ -988,8 +988,8 @@ const StyledWrapper = styled.div`
|
||||
height: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -1251,7 +1251,6 @@ const StyledWrapper = styled.div`
|
||||
.disconnect-modal {
|
||||
.disconnect-message {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -1281,7 +1280,7 @@ const StyledWrapper = styled.div`
|
||||
.action-confirm-modal {
|
||||
.confirm-message {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -1504,11 +1503,15 @@ const StyledWrapper = styled.div`
|
||||
.text-diff-container {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
|
||||
.diff-column-headers {
|
||||
display: flex;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: ${(props) => props.theme.bg};
|
||||
|
||||
.diff-column-label {
|
||||
flex: 1;
|
||||
@@ -1640,6 +1643,16 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.text-diff-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
.text-diff-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -1662,8 +1675,9 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.spec-diff-body {
|
||||
max-height: calc(80vh - 140px);
|
||||
overflow: auto;
|
||||
.text-diff-container {
|
||||
max-height: calc(80vh - 140px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1721,6 +1735,15 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.status.info.text};
|
||||
background: ${(props) => props.theme.status.info.background};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-review-body {
|
||||
@@ -2190,7 +2213,7 @@ const StyledWrapper = styled.div`
|
||||
align-self: stretch;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: ${(props) => props.theme.background.surface2};
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
}
|
||||
|
||||
@@ -2198,7 +2221,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 0 0.65rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: calc(${(props) => props.theme.border.radius.md} - 3px);
|
||||
@@ -2278,6 +2301,26 @@ const StyledWrapper = styled.div`
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.beta-feedback-inline {
|
||||
margin-top: 2rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
.beta-feedback-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: ${(props) => props.theme.status.info.text};
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
IconArrowRight,
|
||||
IconArrowsDiff,
|
||||
IconInfoCircle,
|
||||
IconRefresh
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
@@ -28,8 +28,18 @@ import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'provide
|
||||
* - specRemovedEndpoints: removed from spec, still in collection
|
||||
*/
|
||||
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
|
||||
const specAddedEndpoints = remoteDrift.missing || [];
|
||||
const specRemovedEndpoints = remoteDrift.localOnly || [];
|
||||
// Only show endpoints as "New in Spec" if they were actually added to the spec
|
||||
// (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
|
||||
// still exist in both stored and remote spec should not appear here — they belong
|
||||
// in "Collection Changes" only.
|
||||
const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
|
||||
// Only show endpoints as "Removed from Spec" if they were actually in the stored spec
|
||||
// (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
|
||||
// the spec should not appear here — they belong in "Collection Changes" only.
|
||||
const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
|
||||
// Build lookup sets to determine who changed each modified endpoint
|
||||
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
|
||||
@@ -73,6 +83,7 @@ const SyncReviewPage = ({
|
||||
collectionUid,
|
||||
newSpec,
|
||||
isSyncing,
|
||||
isLoading,
|
||||
onApplySync
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -153,10 +164,7 @@ const SyncReviewPage = ({
|
||||
|
||||
// Accepted — changes that will be applied
|
||||
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to update', 'update', [
|
||||
...specUpdatedEndpoints.filter(isAccepted),
|
||||
...localUpdatedEndpoints.filter(isAccepted)
|
||||
]);
|
||||
addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
|
||||
|
||||
// Skipped — changes that will be preserved as-is
|
||||
@@ -166,7 +174,7 @@ const SyncReviewPage = ({
|
||||
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
|
||||
|
||||
return groups;
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
|
||||
const handleConfirmApply = () => {
|
||||
setShowConfirmation(false);
|
||||
@@ -186,7 +194,6 @@ const SyncReviewPage = ({
|
||||
|
||||
onApplySync({
|
||||
endpointDecisions: decisions,
|
||||
removedIds: [],
|
||||
localOnlyIds,
|
||||
// Pass filtered categorized endpoints for performSync to construct the right backend diff
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
@@ -250,9 +257,19 @@ const SyncReviewPage = ({
|
||||
<div className="sync-review-body">
|
||||
{!hasRemoteUpdates ? (
|
||||
<div className="sync-review-empty-state">
|
||||
<IconRefresh size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The collection matches the latest spec. Nothing to sync.</p>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
|
||||
<h4>Checking for updates</h4>
|
||||
<p>Comparing your last synced spec with the latest spec...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The spec endpoints have not been updated since the last sync.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="endpoints-review-sections">
|
||||
@@ -264,7 +281,7 @@ const SyncReviewPage = ({
|
||||
title="Updated in Spec"
|
||||
type="spec-modified"
|
||||
endpoints={specUpdatedEndpoints}
|
||||
defaultExpanded={hasConflicts}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="The spec has updates for these endpoints"
|
||||
headerExtra={conflictCount > 0 ? (
|
||||
@@ -300,7 +317,7 @@ const SyncReviewPage = ({
|
||||
title="New in Spec"
|
||||
type="added"
|
||||
endpoints={specAddedEndpoints}
|
||||
defaultExpanded={false}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="New endpoints from the spec"
|
||||
collectionUid={collectionUid}
|
||||
@@ -324,7 +341,7 @@ const SyncReviewPage = ({
|
||||
title="Removed from Spec"
|
||||
type="removed"
|
||||
endpoints={specRemovedEndpoints}
|
||||
defaultExpanded={false}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="These endpoints are in your collection but not in the spec"
|
||||
collectionUid={collectionUid}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { clearCollectionState, setCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import { countEndpoints } from '../utils';
|
||||
|
||||
const useOpenAPISync = (collection) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -29,6 +30,16 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const updateStoredSpec = (spec) => {
|
||||
setStoredSpec(spec);
|
||||
dispatch(setStoredSpecMeta({
|
||||
collectionUid: collection.uid,
|
||||
title: spec?.info?.title || null,
|
||||
version: spec?.info?.version || null,
|
||||
endpointCount: spec ? countEndpoints(spec) : null
|
||||
}));
|
||||
};
|
||||
|
||||
// Flatten collection items including nested items in folders
|
||||
const allHttpItems = useMemo(() => {
|
||||
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
|
||||
@@ -77,6 +88,7 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
const prevItemCountRef = useRef(httpItemCount);
|
||||
const isDriftLoadingRef = useRef(false);
|
||||
const specDriftRef = useRef(specDrift);
|
||||
|
||||
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
||||
if (isDriftLoadingRef.current && !clear) return;
|
||||
@@ -86,8 +98,7 @@ const useOpenAPISync = (collection) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
@@ -113,6 +124,7 @@ const useOpenAPISync = (collection) => {
|
||||
setFileNotFound(false);
|
||||
setSpecDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setCollectionDrift(null);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -135,9 +147,7 @@ const useOpenAPISync = (collection) => {
|
||||
}
|
||||
|
||||
setSpecDrift(result);
|
||||
if (result.storedSpec) {
|
||||
setStoredSpec(result.storedSpec);
|
||||
}
|
||||
updateStoredSpec(result.storedSpec || null);
|
||||
|
||||
// Update Redux store so toolbar status stays in sync
|
||||
dispatch(setCollectionUpdate({
|
||||
@@ -151,7 +161,6 @@ const useOpenAPISync = (collection) => {
|
||||
if (result.newSpec) {
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
if (remoteComparison.error) {
|
||||
@@ -211,11 +220,11 @@ const useOpenAPISync = (collection) => {
|
||||
try {
|
||||
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
|
||||
if (specType !== 'openapi') {
|
||||
setError('The URL does not point to a valid OpenAPI specification');
|
||||
setError('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError('The URL does not point to a valid OpenAPI specification');
|
||||
setError('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -256,7 +265,6 @@ const useOpenAPISync = (collection) => {
|
||||
if (result.newSpec) {
|
||||
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
|
||||
@@ -269,8 +277,7 @@ const useOpenAPISync = (collection) => {
|
||||
// Collection matches — save spec file silently to complete setup
|
||||
await ipcRenderer.invoke('renderer:save-openapi-spec', {
|
||||
collectionPath: collection.pathname,
|
||||
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2),
|
||||
sourceUrl: trimmedUrl
|
||||
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -289,7 +296,6 @@ const useOpenAPISync = (collection) => {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl,
|
||||
deleteSpecFile: true
|
||||
});
|
||||
setSourceUrl('');
|
||||
@@ -314,8 +320,30 @@ const useOpenAPISync = (collection) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Reload drift — passed to useEndpointActions so it can refresh after actions
|
||||
const reloadDrift = () => loadCollectionDrift({ clear: true });
|
||||
// Keep ref in sync so reloadDrift always reads the latest specDrift
|
||||
specDriftRef.current = specDrift;
|
||||
|
||||
// Reload both drifts — passed to useEndpointActions so it can refresh after actions.
|
||||
// Uses specDriftRef to avoid stale closure over specDrift state.
|
||||
const reloadDrift = async () => {
|
||||
await loadCollectionDrift({ clear: true });
|
||||
// Refresh remoteDrift if we have a remote spec cached from the last check
|
||||
const currentSpecDrift = specDriftRef.current;
|
||||
if (currentSpecDrift?.newSpec) {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: currentSpecDrift.newSpec
|
||||
});
|
||||
if (!remoteComparison.error) {
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reloading remote drift:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save connection settings from the modal
|
||||
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
|
||||
@@ -328,11 +356,11 @@ const useOpenAPISync = (collection) => {
|
||||
try {
|
||||
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
|
||||
} catch {
|
||||
toast.error('The URL does not point to a valid OpenAPI specification');
|
||||
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
throw new Error('Invalid OpenAPI specification');
|
||||
}
|
||||
if (specType !== 'openapi') {
|
||||
toast.error('The URL does not point to a valid OpenAPI specification');
|
||||
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
throw new Error('Invalid OpenAPI specification');
|
||||
}
|
||||
}
|
||||
@@ -342,7 +370,6 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
oldSourceUrl: openApiSyncConfig?.sourceUrl,
|
||||
config: {
|
||||
sourceUrl: newUrl,
|
||||
autoCheck,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const useSyncFlow = ({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
sourceUrl, setError, checkForUpdates
|
||||
setError, checkForUpdates
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -14,13 +14,13 @@ const useSyncFlow = ({
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { removedIds: [], localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
const {
|
||||
removedIds = [], localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
|
||||
} = selections;
|
||||
|
||||
@@ -49,9 +49,7 @@ const useSyncFlow = ({
|
||||
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
|
||||
filteredDiff = {
|
||||
...specDrift,
|
||||
removed: removedIds.length > 0
|
||||
? (specDrift?.removed || []).filter((ep) => removedIds.includes(ep.id))
|
||||
: []
|
||||
removed: [] // Removals handled via localOnlyToRemove
|
||||
};
|
||||
|
||||
localOnlyToRemove = localOnlyIds.length > 0
|
||||
@@ -67,9 +65,8 @@ const useSyncFlow = ({
|
||||
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
addNewRequests: mode !== 'spec-only',
|
||||
removeDeletedRequests: removedIds.length > 0 || localOnlyIds.length > 0,
|
||||
removeDeletedRequests: localOnlyIds.length > 0,
|
||||
diff: filteredDiff,
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
@@ -113,10 +110,28 @@ const useSyncFlow = ({
|
||||
setPendingSyncMode(null);
|
||||
};
|
||||
|
||||
// Only treat endpoints as spec changes if they actually changed in the spec
|
||||
// (not locally-added/deleted endpoints that were never in or removed from the spec)
|
||||
const specAddedIds = useMemo(() => {
|
||||
return new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const specRemovedIds = useMemo(() => {
|
||||
return new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const handleRestoreSpec = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({ localOnlyIds, endpointDecisions: {} }, 'sync');
|
||||
};
|
||||
|
||||
const handleConfirmModalSync = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || []).map((ep) => ep.id);
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({
|
||||
removedIds: [],
|
||||
localOnlyIds,
|
||||
endpointDecisions: {}
|
||||
}, pendingSyncMode || 'sync');
|
||||
@@ -125,21 +140,23 @@ const useSyncFlow = ({
|
||||
const confirmGroups = useMemo(() => {
|
||||
if (!remoteDrift) return [];
|
||||
const groups = [];
|
||||
if (remoteDrift.missing?.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: remoteDrift.missing });
|
||||
const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
if (actuallyAdded.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
|
||||
}
|
||||
if (remoteDrift.modified?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
|
||||
}
|
||||
if (remoteDrift.localOnly?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: remoteDrift.localOnly });
|
||||
const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
if (actuallyRemoved.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
|
||||
}
|
||||
return groups;
|
||||
}, [remoteDrift]);
|
||||
}, [remoteDrift, specAddedIds, specRemovedIds]);
|
||||
|
||||
return {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleSyncNow,
|
||||
handleSyncNow, handleRestoreSpec,
|
||||
handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { IconClock } from '@tabler/icons';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OpenAPISyncHeader from './OpenAPISyncHeader';
|
||||
@@ -130,48 +129,35 @@ const OpenAPISyncTab = ({ collection }) => {
|
||||
remoteDrift={remoteDrift}
|
||||
onTabSelect={setActiveTab}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
fileNotFound={fileNotFound}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collection-changes' && (
|
||||
<div className="sync-tab-content">
|
||||
|
||||
{collectionDrift && !collectionDrift.noStoredSpec ? (
|
||||
<CollectionStatusSection
|
||||
collection={collection}
|
||||
collectionDrift={collectionDrift}
|
||||
reloadDrift={reloadDrift}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
lastSyncDate={openApiSyncConfig?.lastSyncDate}
|
||||
onOpenEndpoint={openEndpointInTab}
|
||||
/>
|
||||
) : !isDriftLoading && !isLoading && (
|
||||
<>
|
||||
<div className="spec-update-banner warning">
|
||||
<div className="banner-left">
|
||||
<div className="status-dot warning" />
|
||||
<span className="banner-title">
|
||||
{openApiSyncConfig?.lastSyncDate
|
||||
? 'Last synced spec is required to show collection changes. Restore the latest spec from the source to track future changes..'
|
||||
: 'Collection changes will be available after the initial sync'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconClock size={40} className="empty-state-icon" />
|
||||
<h4>{openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}</h4>
|
||||
<p>{openApiSyncConfig?.lastSyncDate
|
||||
? 'Restore the latest spec from the source to track future changes..'
|
||||
: 'Once you sync your collection with the spec, changes will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CollectionStatusSection
|
||||
collection={collection}
|
||||
collectionDrift={collectionDrift}
|
||||
reloadDrift={reloadDrift}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
lastSyncDate={openApiSyncConfig?.lastSyncDate}
|
||||
onOpenEndpoint={openEndpointInTab}
|
||||
isLoading={isDriftLoading || isLoading}
|
||||
onTabSelect={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -195,6 +181,7 @@ const OpenAPISyncTab = ({ collection }) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{showSettingsModal && (
|
||||
|
||||
16
packages/bruno-app/src/components/OpenAPISyncTab/utils.js
Normal file
16
packages/bruno-app/src/components/OpenAPISyncTab/utils.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
||||
|
||||
/**
|
||||
* Count the number of HTTP endpoints in an OpenAPI spec.
|
||||
* Returns null if the spec has no paths (e.g. spec is null/undefined).
|
||||
*/
|
||||
export const countEndpoints = (spec) => {
|
||||
if (!spec?.paths) return null;
|
||||
let count = 0;
|
||||
for (const path of Object.values(spec.paths)) {
|
||||
for (const key of Object.keys(path)) {
|
||||
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
@@ -8,17 +8,19 @@ import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconFlask } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
|
||||
|
||||
/**
|
||||
* Add beta features here.
|
||||
* Example:
|
||||
* {
|
||||
* id: 'nodevm',
|
||||
* label: 'Node VM Runtime',
|
||||
* description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
|
||||
* }
|
||||
* UI metadata for beta features rendered in Preferences.
|
||||
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
|
||||
*/
|
||||
const BETA_FEATURES = [];
|
||||
const BETA_FEATURES = [
|
||||
{
|
||||
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
|
||||
label: 'OpenAPI Sync',
|
||||
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
|
||||
}
|
||||
];
|
||||
|
||||
const Beta = ({ close }) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -45,6 +47,7 @@ const Beta = ({ close }) => {
|
||||
const betaSchema = generateValidationSchema();
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: generateInitialValues(),
|
||||
validationSchema: betaSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -61,22 +64,28 @@ const Beta = ({ close }) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
beta: newBetaPreferences
|
||||
beta: {
|
||||
...preferences.beta,
|
||||
...newBetaPreferences
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
betaSchema.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => {
|
||||
handleSave(validatedValues);
|
||||
handleSaveRef.current(validatedValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
}, 500),
|
||||
[handleSave, betaSchema]
|
||||
[betaSchema]
|
||||
);
|
||||
|
||||
// Auto-save when form values change
|
||||
@@ -85,7 +94,7 @@ const Beta = ({ close }) => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const Cache = () => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
|
||||
@@ -38,11 +38,14 @@ const Font = () => {
|
||||
});
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((font, fontSize) => {
|
||||
handleSave(font, fontSize);
|
||||
handleSaveRef.current(font, fontSize);
|
||||
}, 500),
|
||||
[handleSave]
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +55,7 @@ const Font = () => {
|
||||
}
|
||||
debouncedSave(codeFont, codeFontSize);
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [codeFont, codeFontSize, debouncedSave]);
|
||||
|
||||
|
||||
@@ -127,16 +127,19 @@ const General = () => {
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
preferencesSchema.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => {
|
||||
handleSave(validatedValues);
|
||||
handleSaveRef.current(validatedValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
}, 500),
|
||||
[handleSave]
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,7 +147,7 @@ const General = () => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
|
||||
@@ -1,199 +1,53 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
max-height: calc(100% - 30px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
border-collapse: collapse;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reset-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
border-radius: 6px;
|
||||
padding: 4px 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.button.secondary.hoverBg};
|
||||
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keybinding-row .edit-btn,
|
||||
.keybinding-row .reset-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.keybinding-row:hover .edit-btn {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.shortcut-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 260px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
flex-shrink: 0;
|
||||
|
||||
caret-color: ${(props) => props.theme.table.input.color};
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
font-family: monospace;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
thead th {
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 8px;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-input--error {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip-mod.tooltip-mod--error{
|
||||
color: ${(props) => props.theme.status.danger.text} !important;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-bottom: 24px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
border-radius: 8px;
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
thead th:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead th:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
background: ${(props) => props.theme.background.base};
|
||||
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
.key-button {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
opacity: 0.7;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,516 +1,14 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconReload, IconPencil } from '@tabler/icons';
|
||||
import React from 'react';
|
||||
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
// Stored tokens must match your preferences defaults (lowercase)
|
||||
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
|
||||
|
||||
const REQUIRED_MODIFIERS_BY_OS = {
|
||||
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
|
||||
windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count
|
||||
};
|
||||
|
||||
const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
|
||||
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
|
||||
|
||||
const sortCombo = (arr) => {
|
||||
const order = ['ctrl', 'command', 'alt', 'shift'];
|
||||
const modifiers = [];
|
||||
const nonModifiers = [];
|
||||
|
||||
// Separate modifiers from non-modifiers
|
||||
arr.forEach((key) => {
|
||||
if (order.includes(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
nonModifiers.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort modifiers by their order
|
||||
modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
|
||||
// Keep non-modifiers in the order they were pressed (don't sort them)
|
||||
return [...modifiers, ...nonModifiers];
|
||||
};
|
||||
|
||||
const uniqSorted = (arr) => {
|
||||
// Remove duplicates while preserving order
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
arr.forEach((key) => {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(key);
|
||||
}
|
||||
});
|
||||
return sortCombo(unique);
|
||||
};
|
||||
|
||||
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
|
||||
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
|
||||
|
||||
// Signature MUST be stable: unique + sorted
|
||||
const comboSignature = (arr) => toKeysString(arr);
|
||||
|
||||
// OS reserved shortcuts in stored-token format
|
||||
const RESERVED_BY_OS = {
|
||||
mac: new Set([
|
||||
comboSignature(['command', 'q']),
|
||||
comboSignature(['command', 'w']),
|
||||
comboSignature(['command', 'h']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['command', 'space']),
|
||||
comboSignature(['ctrl', 'command', 'q']),
|
||||
comboSignature(['command', ',']),
|
||||
comboSignature(['command', 'shift', '3']),
|
||||
comboSignature(['command', 'shift', '4']),
|
||||
comboSignature(['command', 'shift', '5']),
|
||||
comboSignature(['command', 'alt', 'esc'])
|
||||
]),
|
||||
windows: new Set([
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc'])
|
||||
])
|
||||
};
|
||||
|
||||
// normalize keyboard event -> stored tokens
|
||||
const normalizeKey = (e) => {
|
||||
const k = e.key;
|
||||
|
||||
// ignore lock keys
|
||||
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
|
||||
|
||||
if (k === ' ') return 'space';
|
||||
if (k === 'Escape') return 'esc';
|
||||
if (k === 'Control') return 'ctrl';
|
||||
if (k === 'Alt') return 'alt';
|
||||
if (k === 'Shift') return 'shift';
|
||||
if (k === 'Enter') return 'enter';
|
||||
if (k === 'Backspace') return 'backspace';
|
||||
if (k === 'Tab') return 'tab';
|
||||
if (k === 'Delete') return 'delete';
|
||||
|
||||
// Meta -> command (matches your stored default format)
|
||||
if (k === 'Meta') return 'command';
|
||||
|
||||
// single char (letters/punct) to lowercase
|
||||
if (k.length === 1) return k.toLowerCase();
|
||||
|
||||
// ArrowUp -> arrowup, PageUp -> pageup, etc
|
||||
return k.toLowerCase();
|
||||
};
|
||||
|
||||
const ERROR = {
|
||||
EMPTY: 'EMPTY',
|
||||
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
|
||||
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
|
||||
RESERVED: 'RESERVED',
|
||||
DUPLICATE: 'DUPLICATE',
|
||||
CONFLICT: 'CONFLICT'
|
||||
};
|
||||
|
||||
const Keybindings = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const os = getOS();
|
||||
|
||||
// Source of truth: merge defaults with user preferences
|
||||
const keyBindings = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
// Override with user preferences
|
||||
const userBindings = preferences?.keyBindings || {};
|
||||
for (const [action, binding] of Object.entries(userBindings)) {
|
||||
if (merged[action]) {
|
||||
// Merge user's OS-specific overrides into defaults
|
||||
merged[action] = {
|
||||
...merged[action],
|
||||
...binding
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [preferences?.keyBindings]);
|
||||
|
||||
// Build table data (action -> { name, keys })
|
||||
const keyMapping = useMemo(() => {
|
||||
const out = {};
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] };
|
||||
}
|
||||
return out;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
// ✏️ which row is allowed to edit (pencil clicked)
|
||||
const [editingAction, setEditingAction] = useState(null);
|
||||
|
||||
// hover tracking (for showing pencil/reset only on hover row)
|
||||
const [hoveredAction, setHoveredAction] = useState(null);
|
||||
|
||||
// Recording state
|
||||
const [recordingAction, setRecordingAction] = useState(null);
|
||||
const pressedKeysRef = useRef(new Set());
|
||||
const inputRefs = useRef({});
|
||||
const [draftByAction, setDraftByAction] = useState({}); // action -> string[]
|
||||
const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message }
|
||||
|
||||
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
|
||||
const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || '';
|
||||
|
||||
const isRowDirty = (action) => {
|
||||
const current = getCurrentRowKeysString(action);
|
||||
const def = getDefaultRowKeysString(action);
|
||||
if (!DEFAULT_KEY_BINDINGS) return false;
|
||||
return current !== def;
|
||||
};
|
||||
|
||||
// Check if any keybinding is dirty (different from default)
|
||||
const hasDirtyRows = useMemo(() => {
|
||||
for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) {
|
||||
if (isRowDirty(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
const buildUsedSignatures = (excludeAction) => {
|
||||
const used = new Set();
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (action === excludeAction) continue;
|
||||
const keysStr = binding?.[os];
|
||||
if (!keysStr) continue;
|
||||
used.add(comboSignature(fromKeysString(keysStr)));
|
||||
}
|
||||
return used;
|
||||
};
|
||||
|
||||
const validateCombo = (action, arrRaw) => {
|
||||
const arr = uniqSorted(arrRaw);
|
||||
const sig = comboSignature(arr);
|
||||
|
||||
if (!sig) return { code: ERROR.EMPTY, message: `Shortcut can’t be empty.` };
|
||||
if (isOnlyModifiers(arr))
|
||||
return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' };
|
||||
|
||||
// OS-specific must-have modifier rule
|
||||
if (!hasRequiredModifier(os, arr)) {
|
||||
return {
|
||||
code: ERROR.MISSING_REQUIRED_MOD,
|
||||
message:
|
||||
os === 'mac'
|
||||
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
|
||||
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
|
||||
};
|
||||
}
|
||||
|
||||
// OS reserved
|
||||
if (RESERVED_BY_OS[os]?.has(sig))
|
||||
return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' };
|
||||
|
||||
// No duplicates (across all other actions)
|
||||
if (buildUsedSignatures(action).has(sig))
|
||||
return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' };
|
||||
|
||||
// Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A)
|
||||
for (const [otherAction, binding] of Object.entries(keyBindings)) {
|
||||
if (otherAction === action) continue;
|
||||
const otherKeysStr = binding?.[os];
|
||||
if (!otherKeysStr) continue;
|
||||
|
||||
const otherKeys = fromKeysString(otherKeysStr);
|
||||
|
||||
// Check if current is a subset of other (current is shorter)
|
||||
if (arr.length < otherKeys.length) {
|
||||
const isSubset = arr.every((k) => otherKeys.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if other is a subset of current (current is longer)
|
||||
if (arr.length > otherKeys.length) {
|
||||
const isSubset = otherKeys.every((k) => arr.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistToPreferences = (action, nextKeys) => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {
|
||||
...(preferences?.keyBindings || {}),
|
||||
[action]: {
|
||||
...(preferences?.keyBindings?.[action] || {}),
|
||||
name: preferences?.keyBindings?.[action]?.name || DEFAULT_KEY_BINDINGS?.[action]?.name || action,
|
||||
[os]: nextKeys
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid.
|
||||
const commitCombo = (action) => {
|
||||
const draftArr = draftByAction[action] || [];
|
||||
if (!draftArr.length) return;
|
||||
|
||||
const arr = uniqSorted(draftArr);
|
||||
const err = validateCombo(action, arr);
|
||||
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
const nextKeys = toKeysString(arr);
|
||||
const currentKeys = getCurrentRowKeysString(action);
|
||||
if (nextKeys === currentKeys) return true;
|
||||
|
||||
persistToPreferences(action, nextKeys);
|
||||
// toast success for 2s with Command name
|
||||
const commandName = keyBindings?.[action]?.name || action;
|
||||
toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 });
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetRowToDefault = (action) => {
|
||||
const def = DEFAULT_KEY_BINDINGS?.[action]?.[os];
|
||||
if (!def) return;
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
persistToPreferences(action, def);
|
||||
};
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
// if another row is editing, commit/stop it first
|
||||
if (editingAction && editingAction !== action) {
|
||||
const ok = commitCombo(editingAction);
|
||||
if (ok) {
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
} else {
|
||||
// keep previous row editing if invalid
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditingAction(action);
|
||||
setRecordingAction(action);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
// seed draft with current value
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: fromKeysString(getCurrentRowKeysString(action))
|
||||
}));
|
||||
|
||||
// clear error on start edit
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRefs.current[action]?.focus?.();
|
||||
inputRefs.current[action]?.setSelectionRange?.(
|
||||
inputRefs.current[action].value.length,
|
||||
inputRefs.current[action].value.length
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const stopEditing = (action) => {
|
||||
const ok = commitCombo(action);
|
||||
if (!ok) {
|
||||
// If commit failed (validation error), reset to original value
|
||||
cancelEditing(action);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
// Reset draft to original value and clear error (used on blur with invalid state)
|
||||
const cancelEditing = (action) => {
|
||||
// Clear error for this action
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
// Reset draft to current saved value
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
const handleKeyDown = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// allow user to clear and keep editing (do NOT auto-stop)
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
pressedKeysRef.current = new Set();
|
||||
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
|
||||
setErrorByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) return;
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.add(keyName);
|
||||
|
||||
const currentDraft = uniqSorted(Array.from(pressedKeysRef.current));
|
||||
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: currentDraft
|
||||
}));
|
||||
|
||||
const err = validateCombo(action, currentDraft);
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
} else {
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.delete(keyName);
|
||||
|
||||
// commit only when released AND currently valid
|
||||
if (pressedKeysRef.current.size === 0) {
|
||||
const currentDraft = draftByAction[action] || [];
|
||||
|
||||
// if empty -> keep editing
|
||||
if (currentDraft.length === 0) return;
|
||||
|
||||
// if error -> keep editing
|
||||
if (errorByAction[action]?.message) return;
|
||||
|
||||
stopEditing(action);
|
||||
}
|
||||
};
|
||||
|
||||
const renderValue = (action) => {
|
||||
const arr
|
||||
= recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action));
|
||||
|
||||
return (arr || []).join(' + ');
|
||||
};
|
||||
const Keybindings = ({ close }) => {
|
||||
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">
|
||||
<span>Keybindings</span>
|
||||
{hasDirtyRows && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-all-btn"
|
||||
onClick={resetAllKeybindings}
|
||||
title="Reset all keybindings to default"
|
||||
>
|
||||
<IconReload size={14} stroke={1} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-header">Keybindings</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -521,89 +19,18 @@ const Keybindings = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyMapping ? (
|
||||
Object.entries(keyMapping).map(([action, row]) => {
|
||||
const isEditing = editingAction === action;
|
||||
const isHovered = hoveredAction === action;
|
||||
const isDirty = isRowDirty(action);
|
||||
|
||||
const showPencil = isHovered && !isEditing && !isDirty;
|
||||
const showReset = isDirty && !isEditing;
|
||||
const hasError = Boolean(errorByAction[action]?.message);
|
||||
const errorMessage = errorByAction[action]?.message;
|
||||
const inputId = `kb-input-${action}`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={action}
|
||||
data-testid={`keybinding-row-${action}`}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
|
||||
>
|
||||
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
|
||||
<td>
|
||||
<div className="keybinding-row">
|
||||
<div className="shortcut-wrap">
|
||||
<input
|
||||
id={inputId}
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current[action] = el;
|
||||
}}
|
||||
data-testid={`keybinding-input-${action}`}
|
||||
className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`}
|
||||
value={renderValue(action)}
|
||||
readOnly={!isEditing}
|
||||
onKeyDown={(e) => handleKeyDown(action, e)}
|
||||
onKeyUp={(e) => { handleKeyUp(action, e); }}
|
||||
onBlur={() => {
|
||||
// If there's an error, reset to original value instead of keeping invalid state
|
||||
if (isEditing && hasError) {
|
||||
cancelEditing(action);
|
||||
} else if (isEditing) {
|
||||
stopEditing(action);
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{isEditing && hasError && (
|
||||
<Tooltip
|
||||
id={`kb-editing-error-tooltip-${action}`}
|
||||
anchorSelect={`#${inputId}`}
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
isOpen={true}
|
||||
content={errorMessage}
|
||||
className="tooltip-mod tooltip-mod--error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showReset ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
data-testid={`keybinding-reset-${action}`}
|
||||
onClick={() => resetRowToDefault(action)}
|
||||
title="Reset to default"
|
||||
>
|
||||
<IconReload size={14} stroke={1} />
|
||||
</button>
|
||||
) : null}
|
||||
{showPencil ? (
|
||||
<button
|
||||
type="button"
|
||||
className="edit-btn"
|
||||
data-testid={`keybinding-edit-${action}`}
|
||||
onClick={() => startEditing(action)}
|
||||
title="Edit shortcut"
|
||||
>
|
||||
<IconPencil size={14} stroke={1.5} />
|
||||
</button>
|
||||
) : null}
|
||||
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
|
||||
<tr key={index}>
|
||||
<td>{name}</td>
|
||||
<td>
|
||||
{keys.split('+').map((key, i) => (
|
||||
<div className="key-button" key={i}>
|
||||
{key}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="2">No key bindings available</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
@@ -75,41 +75,26 @@ const ProxySettings = ({ close }) => {
|
||||
});
|
||||
}, [dispatch, preferences, proxySchema]);
|
||||
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
onUpdate(values);
|
||||
onUpdateRef.current(values);
|
||||
}, 500),
|
||||
[onUpdate]
|
||||
[]
|
||||
);
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setValues({
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
port: preferences.proxy.config?.port || '',
|
||||
auth: {
|
||||
disabled: preferences.proxy.config?.auth?.disabled || false,
|
||||
username: preferences.proxy.config?.auth?.username || '',
|
||||
password: preferences.proxy.config?.auth?.password || ''
|
||||
},
|
||||
bypassProxy: preferences.proxy.config?.bypassProxy || ''
|
||||
}
|
||||
});
|
||||
}, [preferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty) {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, debouncedSave]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
|
||||
@@ -3,7 +3,7 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
padding: 12px;
|
||||
min-width: 180px;
|
||||
min-width: 160px;
|
||||
|
||||
div.tab {
|
||||
display: flex;
|
||||
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
section.tab-panel {
|
||||
max-height: calc(100% - 24px);
|
||||
min-height: 70vh;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
|
||||
@@ -33,7 +33,7 @@ const RequestNotFound = ({ itemUid }) => {
|
||||
const errors = [
|
||||
{
|
||||
title: 'Request no longer exists',
|
||||
message: 'This can happen when the .bru file associated with this request was deleted on your filesystem.'
|
||||
message: 'This can happen when the file associated with this request was deleted on your filesystem.'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (!activeTabUid || !focusedTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
return <div className="pb-4 px-4">Loading...</div>;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'global-environment-settings') {
|
||||
|
||||
@@ -72,19 +72,46 @@ const StyledWrapper = styled.div`
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.workspace-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
min-width: 150px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-name-input {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
.cog-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
IconUpload
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -24,14 +24,18 @@ import toast from 'react-hot-toast';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import classNames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
|
||||
const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -42,17 +46,25 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
|
||||
// Get the current active workspace
|
||||
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const gitRootPath = collection?.git?.gitRootPath;
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
|
||||
// Workspace rename state
|
||||
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
|
||||
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
|
||||
const [workspaceNameError, setWorkspaceNameError] = useState('');
|
||||
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
|
||||
const switcherRef = useRef();
|
||||
const workspaceActionsRef = useRef();
|
||||
const workspaceNameInputRef = useRef(null);
|
||||
const workspaceRenameContainerRef = useRef(null);
|
||||
const openingAdvancedRef = useRef(false);
|
||||
const clickedOutsideRef = useRef(false);
|
||||
const handleSaveRef = useRef(null);
|
||||
const tempWorkspaceUidRef = useRef(null);
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
|
||||
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
|
||||
@@ -68,17 +80,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
|
||||
|
||||
const handleCancelWorkspaceRename = useCallback(() => {
|
||||
if (openingAdvancedRef.current) return;
|
||||
if (currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
return;
|
||||
}
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
}, []);
|
||||
}, [currentWorkspace?.isCreating, currentWorkspace?.uid, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRenamingWorkspace) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
|
||||
handleCancelWorkspaceRename();
|
||||
if (currentWorkspace?.isCreating) {
|
||||
clickedOutsideRef.current = true;
|
||||
handleSaveRef.current?.();
|
||||
} else {
|
||||
handleCancelWorkspaceRename();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,7 +113,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
|
||||
}, [isRenamingWorkspace, handleCancelWorkspaceRename, currentWorkspace?.isCreating]);
|
||||
|
||||
const collectionUpdates = useSelector((state) => state.openapiSync?.collectionUpdates || {});
|
||||
const { theme } = useTheme();
|
||||
@@ -112,7 +134,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
if (isScratch) return false;
|
||||
|
||||
const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || [];
|
||||
return workspaceCollectionPaths.some((wcPath) => c.pathname === wcPath);
|
||||
return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === normalizePath(wcPath));
|
||||
});
|
||||
|
||||
// Count tabs for the current collection
|
||||
@@ -201,8 +223,8 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
// Build overflow menu items for the "..." dropdown
|
||||
const overflowMenuItems = [
|
||||
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
|
||||
...(!hasOpenApiSyncConfigured
|
||||
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }]
|
||||
...(isOpenAPISyncEnabled && !hasOpenApiSyncConfigured
|
||||
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>, onClick: viewOpenApiSync }]
|
||||
: []),
|
||||
{ id: 'collection-settings', label: 'Collection Settings', leftSection: IconSettings, onClick: viewCollectionSettings }
|
||||
];
|
||||
@@ -262,28 +284,71 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
};
|
||||
|
||||
const handleSaveWorkspaceRename = () => {
|
||||
const fromOutside = clickedOutsideRef.current;
|
||||
clickedOutsideRef.current = false;
|
||||
|
||||
if (openingAdvancedRef.current) return;
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
const trimmedName = workspaceNameInput?.trim();
|
||||
if (!trimmedName) {
|
||||
if (fromOutside && currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
return;
|
||||
}
|
||||
setWorkspaceNameError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const error = validateWorkspaceName(workspaceNameInput);
|
||||
if (error) {
|
||||
setWorkspaceNameError(error);
|
||||
if (fromOutside && currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = currentWorkspace?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
|
||||
.then(() => {
|
||||
toast.success('Workspace renamed!');
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while renaming the workspace');
|
||||
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
|
||||
});
|
||||
isSavingRef.current = true;
|
||||
|
||||
if (currentWorkspace?.isCreating) {
|
||||
dispatch(confirmWorkspaceCreation(uid, trimmedName))
|
||||
.then(() => {
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
toast.success('Workspace created!');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while creating the workspace');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingRef.current = false;
|
||||
});
|
||||
} else {
|
||||
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
|
||||
.then(() => {
|
||||
toast.success('Workspace renamed!');
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while renaming the workspace');
|
||||
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingRef.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Keep ref in sync so click-outside handler always has the latest save logic
|
||||
handleSaveRef.current = handleSaveWorkspaceRename;
|
||||
|
||||
const handleWorkspaceNameChange = (e) => {
|
||||
setWorkspaceNameInput(e.target.value);
|
||||
if (workspaceNameError) {
|
||||
@@ -301,6 +366,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdvancedCreate = () => {
|
||||
openingAdvancedRef.current = true;
|
||||
tempWorkspaceUidRef.current = currentWorkspace?.isCreating ? currentWorkspace.uid : null;
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAdvancedCreateClose = () => {
|
||||
openingAdvancedRef.current = false;
|
||||
setCreateWorkspaceModalOpen(false);
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
const tempUid = tempWorkspaceUidRef.current;
|
||||
tempWorkspaceUidRef.current = null;
|
||||
// Clean up the temp workspace (cancelWorkspaceCreation only switches to default
|
||||
// if the temp workspace was still active, so this is safe after modal success too)
|
||||
if (tempUid) {
|
||||
dispatch(cancelWorkspaceCreation(tempUid));
|
||||
}
|
||||
};
|
||||
|
||||
// Check if workspace actions should be shown
|
||||
const showWorkspaceActions = isScratchCollection
|
||||
&& currentWorkspace
|
||||
@@ -316,30 +402,46 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={handleAdvancedCreateClose} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 py-2 px-4">
|
||||
{/* Left side: Switcher dropdown or rename input */}
|
||||
<div className="collection-switcher">
|
||||
{isRenamingWorkspace ? (
|
||||
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
|
||||
<DisplayIcon size={18} strokeWidth={1.5} />
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
type="text"
|
||||
className="workspace-name-input"
|
||||
value={workspaceNameInput}
|
||||
onChange={handleWorkspaceNameChange}
|
||||
onKeyDown={handleWorkspaceNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="workspace-input-wrapper">
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
type="text"
|
||||
className="workspace-name-input"
|
||||
value={workspaceNameInput}
|
||||
onChange={handleWorkspaceNameChange}
|
||||
onKeyDown={handleWorkspaceNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{currentWorkspace?.isCreating && (
|
||||
<button
|
||||
className="cog-btn"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleOpenAdvancedCreate}
|
||||
title="Advanced options"
|
||||
>
|
||||
<IconSettings size={13} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveWorkspaceRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
title={currentWorkspace?.isCreating ? 'Create' : 'Save'}
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -461,8 +563,8 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
{/* Right side: Actions (only for regular collections) */}
|
||||
{!isScratchCollection && (
|
||||
<div className="flex flex-grow gap-1.5 items-center justify-end">
|
||||
{/* OpenAPI Sync - standalone only when configured */}
|
||||
{hasOpenApiSyncConfigured && (
|
||||
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
|
||||
{isOpenAPISyncEnabled && hasOpenApiSyncConfigured && (
|
||||
<ToolHint
|
||||
text={hasOpenApiError ? 'OpenAPI Error' : hasOpenApiUpdates ? 'OpenAPI Updates Available' : 'OpenAPI'}
|
||||
toolhintId="OpenApiSyncToolhintId"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode } from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
const getTabInfo = (type, tabName) => {
|
||||
@@ -90,7 +91,8 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
return (
|
||||
<>
|
||||
<OpenAPISyncIcon size={14} className="special-tab-icon flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">OpenAPI</span>
|
||||
<span className="ml-1 tab-name mr-1">OpenAPI</span>
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
|
||||
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const activeTab = tabs.find((t) => t.uid === activeTabUid);
|
||||
|
||||
const menuDropdownRef = useRef();
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
@@ -90,62 +86,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
};
|
||||
}, [item, item?.name, method, setHasOverflow]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseTabFromHotkeys = () => {
|
||||
if (!activeTabUid || !activeTab) return;
|
||||
|
||||
// Only the active tab component should handle this
|
||||
if (tab.uid !== activeTabUid) return;
|
||||
|
||||
// Always compute item for the active tab
|
||||
const activeItem = findItemInCollection(collection, activeTabUid);
|
||||
|
||||
switch (activeTab.type) {
|
||||
case 'request':
|
||||
if (activeItem && hasRequestChanges(activeItem)) {
|
||||
console.log('Item have changes');
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
console.log('Item dont have changes');
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'collection-settings':
|
||||
if (collection?.draft) {
|
||||
setShowConfirmCollectionClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'folder-settings': {
|
||||
const folderItem = findItemInCollection(collection, activeTab.folderUid || tab.folderUid);
|
||||
if (folderItem?.draft) {
|
||||
setShowConfirmFolderClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'environment-settings':
|
||||
if (collection?.environmentsDraft) {
|
||||
setShowConfirmEnvironmentClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('close-active-tab', handleCloseTabFromHotkeys);
|
||||
return () => window.removeEventListener('close-active-tab', handleCloseTabFromHotkeys);
|
||||
}, [dispatch, activeTab, activeTabUid, tab.uid, collection]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
@@ -17,7 +17,7 @@ import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import path from 'utils/common/path';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections';
|
||||
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import { itemSchema } from '@usebruno/schema';
|
||||
@@ -50,7 +50,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
if (!isScratchCollection || !activeWorkspace) return [];
|
||||
|
||||
return (activeWorkspace.collections || []).map((wc) => {
|
||||
const fullCollection = allCollections.find((c) => c.pathname === wc.path);
|
||||
const fullCollection = allCollections.find((c) => normalizePath(c.pathname) === normalizePath(wc.path));
|
||||
// Use stable deterministic UID based on path to avoid duplicate Redux entries
|
||||
const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid();
|
||||
return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' };
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Modal from 'components/Modal';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
|
||||
import Modal from 'components/Modal';
|
||||
import * as path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import Portal from 'components/Portal';
|
||||
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
|
||||
import { uuid } from 'utils/common/index';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -203,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" data-testid="collection-item-clone">
|
||||
<Button type="submit">
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import range from 'lodash/range';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -69,21 +69,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const isSidebarDragging = useSelector((state) => state.app.isDragging);
|
||||
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const userKeyBindings = preferences?.keyBindings || {};
|
||||
const hasCustomCopyBinding = !!userKeyBindings?.copyItem;
|
||||
const hasCustomPasteBinding = !!userKeyBindings?.pasteItem;
|
||||
const hasCustomRenameBinding = !!userKeyBindings?.renameItem;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// We use a single ref for drag and drop.
|
||||
const ref = useRef(null);
|
||||
const menuDropdownRef = useRef(null);
|
||||
|
||||
// Refs to store current handler references for event listeners (avoid stale closures)
|
||||
const copyHandlerRef = useRef(null);
|
||||
const pasteHandlerRef = useRef(null);
|
||||
|
||||
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
|
||||
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
|
||||
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
|
||||
@@ -130,52 +121,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
}, [isTabForItemActive]);
|
||||
|
||||
// Listen for clone-item-open event from Hotkeys provider
|
||||
const isFocusedRef = useRef(isKeyboardFocused);
|
||||
isFocusedRef.current = isKeyboardFocused;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloneItemOpen = () => {
|
||||
// Only open modal if this item is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setCloneItemModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyItemOpen = () => {
|
||||
// Copy item to clipboard if this item is keyboard focused
|
||||
if (isFocusedRef.current && copyHandlerRef.current) {
|
||||
copyHandlerRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteItemOpen = () => {
|
||||
// Paste item from clipboard if this item is keyboard focused
|
||||
if (isFocusedRef.current && pasteHandlerRef.current) {
|
||||
pasteHandlerRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameItemOpen = () => {
|
||||
// Rename item if this item is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setRenameItemModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.addEventListener('copy-item-open', handleCopyItemOpen);
|
||||
window.addEventListener('paste-item-open', handlePasteItemOpen);
|
||||
window.addEventListener('rename-item-open', handleRenameItemOpen);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.removeEventListener('copy-item-open', handleCopyItemOpen);
|
||||
window.removeEventListener('paste-item-open', handlePasteItemOpen);
|
||||
window.removeEventListener('rename-item-open', handleRenameItemOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const determineDropType = (monitor) => {
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
@@ -483,33 +428,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
return items;
|
||||
};
|
||||
|
||||
const handleCopyItem = useCallback(() => {
|
||||
dispatch(copyRequest(item));
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied`);
|
||||
}, [dispatch, item, isFolder]);
|
||||
|
||||
const handlePasteItem = useCallback(() => {
|
||||
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
|
||||
let targetFolderUid = item.uid;
|
||||
if (!isFolder) {
|
||||
const parentFolder = findParentItemInCollection(collection, item.uid);
|
||||
targetFolderUid = parentFolder ? parentFolder.uid : null;
|
||||
}
|
||||
|
||||
dispatch(pasteItem(collectionUid, targetFolderUid))
|
||||
.then(() => {
|
||||
toast.success('Item pasted successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the item');
|
||||
});
|
||||
}, [dispatch, collection, item, isFolder, collectionUid]);
|
||||
|
||||
// Update refs whenever handlers change
|
||||
copyHandlerRef.current = handleCopyItem;
|
||||
pasteHandlerRef.current = handlePasteItem;
|
||||
|
||||
const className = classnames('flex flex-col w-full', {
|
||||
'is-sidebar-dragging': isSidebarDragging
|
||||
});
|
||||
@@ -619,25 +537,52 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyItem = () => {
|
||||
dispatch(copyRequest(item));
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied`);
|
||||
};
|
||||
|
||||
const handlePasteItem = () => {
|
||||
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
|
||||
let targetFolderUid = item.uid;
|
||||
if (!isFolder) {
|
||||
const parentFolder = findParentItemInCollection(collection, item.uid);
|
||||
targetFolderUid = parentFolder ? parentFolder.uid : null;
|
||||
}
|
||||
|
||||
dispatch(pasteItem(collectionUid, targetFolderUid))
|
||||
.then(() => {
|
||||
toast.success('Item pasted successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the item');
|
||||
});
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
const handleKeyDown = (e) => {
|
||||
// Detect Mac by checking both metaKey and platform
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Only use default handler if no custom keybinding is set for copy/paste
|
||||
if (!hasCustomCopyBinding && isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (copyHandlerRef.current) copyHandlerRef.current();
|
||||
} else if (!hasCustomPasteBinding && isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pasteHandlerRef.current) pasteHandlerRef.current();
|
||||
} else if (!hasCustomRenameBinding && e.key === 'F2') {
|
||||
const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem');
|
||||
const renameKey = isMac ? macRenameKey : winRenameKey;
|
||||
|
||||
// Only trigger rename if no modifier keys are pressed (allow Cmd+Enter for run request)
|
||||
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
if (e.key.toLowerCase() === renameKey && !hasModifier) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRenameItemModalOpen(true);
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyItem();
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
|
||||
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
|
||||
|
||||
@@ -56,6 +58,7 @@ import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
|
||||
const EMPTY_STATE_DELAY_MS = 300;
|
||||
|
||||
const Collection = ({ collection, searchText }) => {
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
const { dropdownContainerRef } = useSidebarAccordion();
|
||||
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
@@ -278,34 +281,6 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
}, [isCollectionFocused]);
|
||||
|
||||
// Listen for clone-item-open event from Hotkeys provider
|
||||
const isFocusedRef = useRef(isKeyboardFocused);
|
||||
isFocusedRef.current = isKeyboardFocused;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloneItemOpen = () => {
|
||||
// Only open modal if this collection is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameCollectionOpen = () => {
|
||||
// Only open rename collection modal if this collection is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setShowRenameCollectionModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.addEventListener('rename-item-open', handleRenameCollectionOpen);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.removeEventListener('rename-item-open', handleRenameCollectionOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce showing empty state to prevent flicker
|
||||
// Race condition: isLoading can become false before items batch arrives from IPC
|
||||
useEffect(() => {
|
||||
@@ -382,12 +357,13 @@ const Collection = ({ collection, searchText }) => {
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
...(isOpenAPISyncEnabled ? [{
|
||||
id: 'sync-openapi',
|
||||
leftSection: OpenAPISyncIcon,
|
||||
label: 'OpenAPI',
|
||||
rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>,
|
||||
onClick: openOpenAPISyncTab
|
||||
},
|
||||
}] : []),
|
||||
...(hasCopiedItems
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCheck, IconX, IconSettings } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import toast from 'react-hot-toast';
|
||||
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import { browseDirectory, createCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import Portal from 'components/Portal';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import get from 'lodash/get';
|
||||
import path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { postmanToBruno } from 'utils/importers/postman-collection';
|
||||
@@ -13,6 +13,7 @@ import { processBrunoCollection } from 'utils/importers/bruno-collection';
|
||||
import { processOpenCollection } from 'utils/importers/opencollection';
|
||||
import { wsdlToBruno } from '@usebruno/converters';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import Modal from 'components/Modal';
|
||||
import Help from 'components/Help';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
@@ -101,13 +102,14 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sour
|
||||
const dispatch = useDispatch();
|
||||
const [groupingType, setGroupingType] = useState('tags');
|
||||
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
|
||||
const [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(true);
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
const [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(isOpenAPISyncEnabled);
|
||||
const dropdownTippyRef = useRef();
|
||||
const isOpenApi = format === 'openapi';
|
||||
const isZipImport = format === 'bruno-zip';
|
||||
const isOpenApiFromUrl = isOpenApi && !!sourceUrl && !filePath;
|
||||
const isOpenApiFromFile = isOpenApi && !!filePath && !sourceUrl;
|
||||
const showCheckForSpecUpdatesOption = isOpenApiFromUrl || isOpenApiFromFile;
|
||||
const showCheckForSpecUpdatesOption = isOpenAPISyncEnabled && (isOpenApiFromUrl || isOpenApiFromFile);
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
@@ -104,7 +104,6 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
|
||||
formik.setFieldValue('folderName', e.target.value);
|
||||
!isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
|
||||
}}
|
||||
data-testid="new-folder-input"
|
||||
value={formik.values.folderName || ''}
|
||||
/>
|
||||
{formik.touched.folderName && formik.errors.folderName ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import { useState, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
|
||||
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
@@ -59,22 +58,6 @@ const CollectionsSection = () => {
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
|
||||
// Listen for sidebar-search-open hotkey event
|
||||
useEffect(() => {
|
||||
const handleSidebarSearch = () => {
|
||||
setShowSearch(true);
|
||||
// Focus the search input after it's rendered
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('.collection-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
window.addEventListener('sidebar-search-open', handleSidebarSearch);
|
||||
return () => window.removeEventListener('sidebar-search-open', handleSidebarSearch);
|
||||
}, []);
|
||||
// Default to true (don't show modal) so that:
|
||||
// 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it
|
||||
// 2. The modal doesn't flash before preferences are loaded from the electron process
|
||||
|
||||
@@ -7,7 +7,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -22,11 +21,8 @@ class SingleLineEditor extends Component {
|
||||
this.variables = {};
|
||||
this.readOnly = props.readOnly || false;
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,8 +59,8 @@ class SingleLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
extraKeys: {
|
||||
'Enter': runHandler,
|
||||
// 'Ctrl-Enter': runHandler,
|
||||
// 'Cmd-Enter': runHandler,
|
||||
'Ctrl-Enter': runHandler,
|
||||
'Cmd-Enter': runHandler,
|
||||
'Alt-Enter': () => {
|
||||
if (this.props.allowNewlines) {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
@@ -73,7 +69,7 @@ class SingleLineEditor extends Component {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
// 'Shift-Enter': runHandler,
|
||||
'Shift-Enter': runHandler,
|
||||
'Cmd-S': saveHandler,
|
||||
'Ctrl-S': saveHandler,
|
||||
'Cmd-F': noopHandler,
|
||||
@@ -112,9 +108,6 @@ class SingleLineEditor extends Component {
|
||||
this._updateNewlineMarkers();
|
||||
}
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts using the dedicated utility
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
}
|
||||
|
||||
/** Enable or disable masking the rendered content of the editor */
|
||||
@@ -186,6 +179,10 @@ class SingleLineEditor extends Component {
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
|
||||
// Update newline markers after value change
|
||||
if (this.props.showNewlineArrow) {
|
||||
@@ -209,12 +206,6 @@ class SingleLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.editor?._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
|
||||
@@ -49,7 +49,10 @@ const StatusBar = () => {
|
||||
};
|
||||
|
||||
const openGlobalSearch = () => {
|
||||
window.dispatchEvent(new CustomEvent('global-search-open'));
|
||||
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
|
||||
bindings.forEach((binding) => {
|
||||
Mousetrap.trigger(binding);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 8px 20px;
|
||||
padding: 9px 20px 8px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
|
||||
@@ -73,7 +73,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
@@ -111,7 +111,15 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-inline: 4px;
|
||||
padding-left: 6px;
|
||||
border-radius: 6px;
|
||||
padding-right: 3px;
|
||||
padding-block: 4px;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
@@ -154,7 +162,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
|
||||
@@ -48,7 +48,6 @@ const EnvironmentList = ({
|
||||
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
@@ -83,6 +82,8 @@ const EnvironmentList = ({
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
const globalEnvironmentDraftUid = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft?.environmentUid);
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
@@ -90,10 +91,10 @@ const EnvironmentList = ({
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
} else if (globalEnvironmentDraftUid?.startsWith('dotenv:')) {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
}
|
||||
}, [dispatch, selectedDotEnvFile]);
|
||||
}, [dispatch, selectedDotEnvFile, globalEnvironmentDraftUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
@@ -552,51 +553,66 @@ const EnvironmentList = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
if (!environmentsExpanded) {
|
||||
setEnvironmentsExpanded(true);
|
||||
}
|
||||
handleCreateEnvClick();
|
||||
}}
|
||||
title="Search environments"
|
||||
title="Create environment"
|
||||
>
|
||||
<IconSearch size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) {
|
||||
setEnvironmentsExpanded(true);
|
||||
}
|
||||
handleImportClick();
|
||||
}}
|
||||
title="Import environment"
|
||||
>
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) {
|
||||
setEnvironmentsExpanded(true);
|
||||
}
|
||||
handleExportClick();
|
||||
}}
|
||||
title="Export environment"
|
||||
>
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{isEnvListSearchExpanded && (
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
|
||||
@@ -6,12 +6,17 @@ import ExportEnvironmentModal from 'components/Environments/Common/ExportEnviron
|
||||
|
||||
const WorkspaceEnvironments = ({ workspace }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(() => {
|
||||
const environments = globalEnvironments || [];
|
||||
if (!environments.length) return null;
|
||||
return environments.find((env) => env.uid === activeGlobalEnvironmentUid) || environments[0];
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<EnvironmentList
|
||||
|
||||
@@ -40,7 +40,7 @@ const CreateWorkspace = ({ onClose }) => {
|
||||
if (!value) return true;
|
||||
|
||||
return !workspaces.some((w) =>
|
||||
w.name.toLowerCase() === value.toLowerCase());
|
||||
!w.isCreating && w.name && w.name.toLowerCase() === value.toLowerCase());
|
||||
}),
|
||||
workspaceFolderName: Yup.string()
|
||||
.min(1, 'Must be at least 1 character')
|
||||
|
||||
@@ -187,11 +187,10 @@ const GlobalStyle = createGlobalStyle`
|
||||
|
||||
|
||||
// scrollbar styling
|
||||
// the below media query target non-macos devices
|
||||
// (macos scrollbar styling is the ideal style reference)
|
||||
// the below media query targets non-touch devices
|
||||
@media not all and (pointer: coarse) {
|
||||
* {
|
||||
scrollbar-color: ${(props) => props.theme.scrollbar.color};
|
||||
scrollbar-color: ${(props) => props.theme.scrollbar.color} transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
|
||||
39
packages/bruno-app/src/hooks/useDeferredLoading/index.js
Normal file
39
packages/bruno-app/src/hooks/useDeferredLoading/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A hook that defers showing loading state until a minimum delay has passed.
|
||||
* This prevents flickering UI for fast operations.
|
||||
*
|
||||
* @param {boolean} isLoading - The actual loading state
|
||||
* @param {number} delay - Minimum time (ms) before showing loading state (default: 200ms)
|
||||
* @returns {boolean} - The deferred loading state
|
||||
*/
|
||||
function useDeferredLoading(isLoading, delay = 200) {
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowLoading(true);
|
||||
}, delay);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setShowLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isLoading, delay]);
|
||||
|
||||
return showLoading;
|
||||
}
|
||||
|
||||
export default useDeferredLoading;
|
||||
109
packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js
Normal file
109
packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import useDeferredLoading from './index';
|
||||
|
||||
describe('useDeferredLoading', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return false initially when isLoading is false', () => {
|
||||
const { result } = renderHook(() => useDeferredLoading(false));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should not show loading immediately when isLoading becomes true', () => {
|
||||
const { result } = renderHook(() => useDeferredLoading(true, 200));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should show loading after the delay has passed', () => {
|
||||
const { result } = renderHook(() => useDeferredLoading(true, 200));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should not show loading if isLoading becomes false before delay', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isLoading }) => useDeferredLoading(isLoading, 200),
|
||||
{ initialProps: { isLoading: true } }
|
||||
);
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
rerender({ isLoading: false });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset to false immediately when isLoading becomes false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isLoading }) => useDeferredLoading(isLoading, 200),
|
||||
{ initialProps: { isLoading: true } }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender({ isLoading: false });
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should use default delay of 200ms', () => {
|
||||
const { result } = renderHook(() => useDeferredLoading(true));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(199);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect custom delay values', () => {
|
||||
const { result } = renderHook(() => useDeferredLoading(true, 500));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,16 @@ import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { checkActiveWorkspaceCollectionsForUpdates } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
|
||||
const POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const useOpenAPISyncPolling = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Global toggle for pausing all OpenAPI sync polling (defaults to true, not yet wired to any UI)
|
||||
const pollingEnabled = useSelector((state) => state.openapiSync?.pollingEnabled ?? true);
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
// Global toggle for pausing all OpenAPI sync polling
|
||||
const pollingEnabled = useSelector((state) => state.openapiSync?.pollingEnabled ?? true) && isOpenAPISyncEnabled;
|
||||
const collections = useSelector((state) => state.collections?.collections || []);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
@@ -1,366 +1,290 @@
|
||||
import React, { createContext, useEffect, useContext, useRef, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import find from 'lodash/find';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import GlobalSearchModal from 'components/GlobalSearchModal';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
|
||||
import store from 'providers/ReduxStore/index';
|
||||
import {
|
||||
sendRequest,
|
||||
saveRequest,
|
||||
saveCollectionRoot,
|
||||
saveFolderRoot,
|
||||
saveCollectionSettings,
|
||||
closeTabs,
|
||||
cloneItem,
|
||||
pasteItem
|
||||
closeTabs
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { savePreferences, toggleSidebarCollapse, copyRequest } from 'providers/ReduxStore/slices/app';
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { getKeyBindingsForActionAllOS } from './keyMappings';
|
||||
|
||||
export const HotkeysContext = createContext(null);
|
||||
export const HotkeysContext = React.createContext();
|
||||
|
||||
// List of all actions that are bound in this provider
|
||||
const BOUND_ACTIONS = [
|
||||
'save',
|
||||
'sendRequest',
|
||||
'editEnvironment',
|
||||
'newRequest',
|
||||
'globalSearch',
|
||||
'closeTab',
|
||||
'switchToPreviousTab',
|
||||
'switchToNextTab',
|
||||
'closeAllTabs',
|
||||
'collapseSidebar',
|
||||
'moveTabLeft',
|
||||
'moveTabRight',
|
||||
'changeLayout',
|
||||
'closeBruno',
|
||||
'openPreferences',
|
||||
'importCollection',
|
||||
'sidebarSearch',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'resetZoom',
|
||||
'cloneItem',
|
||||
'copyItem',
|
||||
'pasteItem',
|
||||
'renameItem'
|
||||
];
|
||||
|
||||
/**
|
||||
* Bind a single hotkey action using Mousetrap.
|
||||
* Reads from merged defaults + user preferences via getKeyBindingsForActionAllOS.
|
||||
*/
|
||||
function bindHotkey(action, handler, userKeyBindings) {
|
||||
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
|
||||
if (!combos?.length) return;
|
||||
|
||||
Mousetrap.bind([...combos], (e) => {
|
||||
e?.preventDefault?.();
|
||||
handler(e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind a single hotkey action.
|
||||
*/
|
||||
function unbindHotkey(action, userKeyBindings) {
|
||||
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
|
||||
if (!combos?.length) return;
|
||||
Mousetrap.unbind([...combos]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind all known actions for the given user key bindings.
|
||||
*/
|
||||
function unbindAllHotkeys(userKeyBindings) {
|
||||
BOUND_ACTIONS.forEach((action) => unbindHotkey(action, userKeyBindings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind all hotkey actions.
|
||||
*/
|
||||
function bindAllHotkeys(userKeyBindings) {
|
||||
const { dispatch, getState } = store;
|
||||
|
||||
// SAVE
|
||||
bindHotkey('save', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const collections = state.collections.collections;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return;
|
||||
|
||||
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
|
||||
window.dispatchEvent(new CustomEvent('environment-save'));
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
|
||||
if (item?.uid) {
|
||||
if (activeTab.type === 'folder-settings') {
|
||||
dispatch(saveFolderRoot(collection.uid, item.uid));
|
||||
} else {
|
||||
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
|
||||
}
|
||||
} else if (activeTab.type === 'collection-settings') {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
}
|
||||
}, userKeyBindings);
|
||||
|
||||
// SEND REQUEST
|
||||
bindHotkey('sendRequest', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const collections = state.collections.collections;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return;
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (!item) return;
|
||||
|
||||
if (item.type === 'grpc-request') {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
if (!request.url) return toast.error('Please enter a valid gRPC server URL');
|
||||
if (!request.method) return toast.error('Please select a gRPC method');
|
||||
}
|
||||
|
||||
dispatch(sendRequest(item, collection.uid)).catch(() =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, { duration: 5000 })
|
||||
);
|
||||
}, userKeyBindings);
|
||||
|
||||
// EDIT ENV
|
||||
bindHotkey('editEnvironment', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const collections = state.collections.collections;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return;
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}, userKeyBindings);
|
||||
|
||||
// NEW REQUEST -> trigger via event so the provider can open the modal
|
||||
bindHotkey('newRequest', () => {
|
||||
window.dispatchEvent(new CustomEvent('new-request-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// GLOBAL SEARCH -> trigger via event so the provider can open the modal
|
||||
bindHotkey('globalSearch', () => {
|
||||
window.dispatchEvent(new CustomEvent('global-search-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLOSE TAB
|
||||
bindHotkey('closeTab', () => {
|
||||
window.dispatchEvent(new CustomEvent('close-active-tab'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// SWITCH PREV TAB
|
||||
bindHotkey('switchToPreviousTab', () => {
|
||||
dispatch(switchTab({ direction: 'pageup' }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// SWITCH NEXT TAB
|
||||
bindHotkey('switchToNextTab', () => {
|
||||
dispatch(switchTab({ direction: 'pagedown' }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLOSE ALL TABS
|
||||
bindHotkey('closeAllTabs', () => {
|
||||
window.dispatchEvent(new CustomEvent('close-active-tab'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// COLLAPSE SIDEBAR
|
||||
bindHotkey('collapseSidebar', () => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
}, userKeyBindings);
|
||||
|
||||
// MOVE TAB LEFT
|
||||
bindHotkey('moveTabLeft', () => {
|
||||
dispatch(reorderTabs({ direction: -1 }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// MOVE TAB RIGHT
|
||||
bindHotkey('moveTabRight', () => {
|
||||
dispatch(reorderTabs({ direction: 1 }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CHANGE LAYOUT -> toggle response pane orientation
|
||||
bindHotkey('changeLayout', () => {
|
||||
const state = getState();
|
||||
const preferences = state.app.preferences;
|
||||
const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences.layout,
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLOSE BRUNO -> send IPC to close the window
|
||||
bindHotkey('closeBruno', () => {
|
||||
window.ipcRenderer?.send('renderer:window-close');
|
||||
}, userKeyBindings);
|
||||
|
||||
// OPEN PREFERENCES -> open preferences tab
|
||||
bindHotkey('openPreferences', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
const activeTab = tabs.find((t) => t.uid === activeTabUid);
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
type: 'preferences',
|
||||
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
|
||||
collectionUid: activeTab?.collectionUid
|
||||
})
|
||||
);
|
||||
}, userKeyBindings);
|
||||
|
||||
// IMPORT COLLECTION -> trigger event to open import modal
|
||||
bindHotkey('importCollection', () => {
|
||||
window.dispatchEvent(new CustomEvent('import-collection-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// SIDEBAR SEARCH -> trigger event to focus sidebar search
|
||||
bindHotkey('sidebarSearch', () => {
|
||||
window.dispatchEvent(new CustomEvent('sidebar-search-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// ZOOM IN
|
||||
bindHotkey('zoomIn', () => {
|
||||
window.ipcRenderer?.invoke('renderer:zoom-in');
|
||||
}, userKeyBindings);
|
||||
|
||||
// ZOOM OUT
|
||||
bindHotkey('zoomOut', () => {
|
||||
window.ipcRenderer?.invoke('renderer:zoom-out');
|
||||
}, userKeyBindings);
|
||||
|
||||
// RESET ZOOM
|
||||
bindHotkey('resetZoom', () => {
|
||||
window.ipcRenderer?.invoke('renderer:reset-zoom');
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLONE ITEM -> trigger event so the sidebar can handle opening the clone modal
|
||||
bindHotkey('cloneItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('clone-item-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// COPY ITEM -> copy currently selected item to clipboard
|
||||
bindHotkey('copyItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('copy-item-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// PASTE ITEM -> paste from clipboard to current location
|
||||
bindHotkey('pasteItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('paste-item-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// RENAME ITEM -> trigger event so the sidebar can handle opening the rename modal
|
||||
bindHotkey('renameItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('rename-item-open'));
|
||||
}, userKeyBindings);
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Provider (manages hotkey lifecycle + modal state)
|
||||
// -----------------------
|
||||
export const HotkeysProvider = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings);
|
||||
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
|
||||
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
|
||||
|
||||
// Keep a ref to the previous userKeyBindings so we can unbind old combos
|
||||
const prevKeyBindingsRef = useRef(undefined);
|
||||
|
||||
const getCurrentCollection = () => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return undefined;
|
||||
return findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
return collection;
|
||||
}
|
||||
};
|
||||
|
||||
const currentCollection = getCurrentCollection();
|
||||
|
||||
// Bind/rebind hotkeys whenever user preferences change
|
||||
// save hotkey
|
||||
useEffect(() => {
|
||||
// Store previous bindings before updating
|
||||
const prevBindings = prevKeyBindingsRef.current;
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
|
||||
window.dispatchEvent(new CustomEvent('environment-save'));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unbind previous bindings (if any)
|
||||
if (prevBindings !== undefined) {
|
||||
unbindAllHotkeys(prevBindings);
|
||||
}
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (item && item.uid) {
|
||||
if (activeTab.type === 'folder-settings') {
|
||||
dispatch(saveFolderRoot(collection.uid, item.uid));
|
||||
} else {
|
||||
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
|
||||
}
|
||||
} else if (activeTab.type === 'collection-settings') {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind with current preferences
|
||||
bindAllHotkeys(userKeyBindings);
|
||||
prevKeyBindingsRef.current = userKeyBindings;
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
unbindAllHotkeys(userKeyBindings);
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
|
||||
};
|
||||
}, [userKeyBindings]);
|
||||
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
|
||||
|
||||
// Listen for hotkey-triggered events for modals
|
||||
// send request (ctrl/cmd + enter)
|
||||
useEffect(() => {
|
||||
const openNewRequest = () => setShowNewRequestModal(true);
|
||||
const openGlobalSearch = () => setShowGlobalSearchModal(true);
|
||||
const openImportCollection = () => setShowImportCollectionModal(true);
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
window.addEventListener('new-request-open', openNewRequest);
|
||||
window.addEventListener('global-search-open', openGlobalSearch);
|
||||
window.addEventListener('import-collection-open', openImportCollection);
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (item) {
|
||||
if (item.type === 'grpc-request') {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
if (!request.url) {
|
||||
toast.error('Please enter a valid gRPC server URL');
|
||||
return;
|
||||
}
|
||||
if (!request.method) {
|
||||
toast.error('Please select a gRPC method');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(sendRequest(item, collection.uid)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('new-request-open', openNewRequest);
|
||||
window.removeEventListener('global-search-open', openGlobalSearch);
|
||||
window.removeEventListener('import-collection-open', openImportCollection);
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, saveRequest, collections]);
|
||||
|
||||
// edit environments (ctrl/cmd + e)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch]);
|
||||
|
||||
// new request (ctrl/cmd + b)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
setShowNewRequestModal(true);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
|
||||
|
||||
// global search (ctrl/cmd + k)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => {
|
||||
setShowGlobalSearchModal(true);
|
||||
|
||||
return false; // stop bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// close tab hotkey
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
|
||||
if (activeTabUid) {
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [activeTabUid]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
|
||||
};
|
||||
}, [activeTabUid]);
|
||||
|
||||
// Switch to the previous tab
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToPreviousTab')], (e) => {
|
||||
dispatch(
|
||||
switchTab({
|
||||
direction: 'pageup'
|
||||
})
|
||||
);
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToPreviousTab')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Switch to the next tab
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToNextTab')], (e) => {
|
||||
dispatch(
|
||||
switchTab({
|
||||
direction: 'pagedown'
|
||||
})
|
||||
);
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToNextTab')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Close all tabs
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeAllTabs')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: tabUids
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch]);
|
||||
|
||||
// Collapse sidebar (ctrl/cmd + \)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Move tab left
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => {
|
||||
dispatch(reorderTabs({ direction: -1 }));
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Move tab right
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => {
|
||||
dispatch(reorderTabs({ direction: 1 }));
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const currentCollection = getCurrentCollection();
|
||||
|
||||
return (
|
||||
<HotkeysContext.Provider {...props} value="hotkey">
|
||||
{showNewRequestModal && (
|
||||
@@ -369,16 +293,13 @@ export const HotkeysProvider = (props) => {
|
||||
{showGlobalSearchModal && (
|
||||
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
|
||||
)}
|
||||
{showImportCollectionModal && (
|
||||
<ImportCollection onClose={() => setShowImportCollectionModal(false)} />
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</HotkeysContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useHotkeys = () => {
|
||||
const context = useContext(HotkeysContext);
|
||||
const context = React.useContext(HotkeysContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(`useHotkeys must be used within a HotkeysProvider`);
|
||||
|
||||
@@ -1,76 +1,42 @@
|
||||
export const DEFAULT_KEY_BINDINGS = {
|
||||
save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' },
|
||||
sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' },
|
||||
importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' },
|
||||
globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' },
|
||||
sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' },
|
||||
closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' },
|
||||
changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' },
|
||||
const KeyMapping = {
|
||||
save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' },
|
||||
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
|
||||
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
|
||||
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
|
||||
closeBruno: {
|
||||
mac: 'command+bind+q',
|
||||
windows: 'ctrl+bind+shift+bind+q',
|
||||
mac: 'command+Q',
|
||||
windows: 'ctrl+shift+q',
|
||||
name: 'Close Bruno'
|
||||
},
|
||||
switchToPreviousTab: {
|
||||
mac: 'command+bind+2',
|
||||
windows: 'ctrl+bind+2',
|
||||
mac: 'command+pageup',
|
||||
windows: 'ctrl+pageup',
|
||||
name: 'Switch to Previous Tab'
|
||||
},
|
||||
switchToNextTab: {
|
||||
mac: 'command+bind+1',
|
||||
windows: 'ctrl+bind+1',
|
||||
mac: 'command+pagedown',
|
||||
windows: 'ctrl+pagedown',
|
||||
name: 'Switch to Next Tab'
|
||||
},
|
||||
moveTabLeft: {
|
||||
mac: 'command+bind+[',
|
||||
windows: 'ctrl+bind+[',
|
||||
mac: 'command+shift+pageup',
|
||||
windows: 'ctrl+shift+pageup',
|
||||
name: 'Move Tab Left'
|
||||
},
|
||||
moveTabRight: {
|
||||
mac: 'command+bind+]',
|
||||
windows: 'ctrl+bind+]',
|
||||
mac: 'command+shift+pagedown',
|
||||
windows: 'ctrl+shift+pagedown',
|
||||
name: 'Move Tab Right'
|
||||
},
|
||||
closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' },
|
||||
collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' },
|
||||
zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' },
|
||||
zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' },
|
||||
resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' },
|
||||
cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' },
|
||||
copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' },
|
||||
pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' },
|
||||
renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts keybindings from storage format (+bind+) to Mousetrap format (+)
|
||||
* Storage format uses +bind+ as separator to avoid conflicts with the actual + key
|
||||
* Mousetrap uses + as the separator
|
||||
* Also converts arrow key names to Mousetrap format
|
||||
*
|
||||
* @param {string} keysStr - Keybinding string in storage format
|
||||
* @returns {string|null} Keybinding string in Mousetrap format, or null if empty
|
||||
*/
|
||||
export const toMousetrapCombo = (keysStr) => {
|
||||
if (!keysStr) return null;
|
||||
|
||||
// Split by +bind+ separator
|
||||
const parts = keysStr.split('+bind+').filter(Boolean);
|
||||
|
||||
// Convert arrow key names from browser format to Mousetrap format
|
||||
const converted = parts.map((part) => {
|
||||
const lower = part.toLowerCase();
|
||||
if (lower === 'arrowup') return 'up';
|
||||
if (lower === 'arrowdown') return 'down';
|
||||
if (lower === 'arrowleft') return 'left';
|
||||
if (lower === 'arrowright') return 'right';
|
||||
return lower;
|
||||
});
|
||||
|
||||
return converted.join('+');
|
||||
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
|
||||
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
|
||||
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
|
||||
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
|
||||
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' },
|
||||
renameItem: { mac: 'enter', windows: 'f2', name: 'Rename Collection Item' }
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,7 +47,7 @@ export const toMousetrapCombo = (keysStr) => {
|
||||
*/
|
||||
export const getKeyBindingsForOS = (os) => {
|
||||
const keyBindings = {};
|
||||
for (const [action, { name, ...keys }] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
|
||||
if (keys[os]) {
|
||||
keyBindings[action] = {
|
||||
keys: keys[os],
|
||||
@@ -93,57 +59,18 @@ export const getKeyBindingsForOS = (os) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges default key bindings with user preferences.
|
||||
*
|
||||
* @param {Object} userKeyBindings - User's custom key bindings from preferences (preferences.keyBindings)
|
||||
* @returns {Object} Merged key bindings object
|
||||
*/
|
||||
export const getMergedKeyBindings = (userKeyBindings) => {
|
||||
const merged = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
// Override with user preferences
|
||||
if (userKeyBindings && typeof userKeyBindings === 'object') {
|
||||
for (const [action, binding] of Object.entries(userKeyBindings)) {
|
||||
if (merged[action]) {
|
||||
merged[action] = { ...merged[action], ...binding };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the Mousetrap-compatible key combos for a specific action across all operating systems.
|
||||
* Reads from merged defaults + user preferences.
|
||||
* Retrieves the key bindings for a specific action across all operating systems.
|
||||
*
|
||||
* @param {string} action - The action for which to retrieve key bindings.
|
||||
* @param {Object} [userKeyBindings] - User's custom key bindings from preferences
|
||||
* @returns {string[]|null} Array of Mousetrap-compatible combo strings, or null if the action is not found.
|
||||
* @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
|
||||
*/
|
||||
export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => {
|
||||
const merged = getMergedKeyBindings(userKeyBindings);
|
||||
const actionBindings = merged[action];
|
||||
export const getKeyBindingsForActionAllOS = (action) => {
|
||||
const actionBindings = KeyMapping[action];
|
||||
|
||||
if (!actionBindings) {
|
||||
console.warn(`Action "${action}" not found in KeyMapping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const combos = [];
|
||||
if (actionBindings.mac) {
|
||||
const combo = toMousetrapCombo(actionBindings.mac);
|
||||
if (combo) combos.push(combo);
|
||||
}
|
||||
if (actionBindings.windows) {
|
||||
const combo = toMousetrapCombo(actionBindings.windows);
|
||||
if (combo) combos.push(combo);
|
||||
}
|
||||
|
||||
return combos.length > 0 ? combos : null;
|
||||
return [actionBindings.mac, actionBindings.windows];
|
||||
};
|
||||
|
||||
@@ -9,7 +9,9 @@ const initialState = {
|
||||
// Last poll timestamp
|
||||
lastPollTime: null,
|
||||
// Map of collectionUid -> { activeTab, expandedSections, expandedRows }
|
||||
tabUiState: {}
|
||||
tabUiState: {},
|
||||
// Map of collectionUid -> { title, version, endpointCount } (persists across tab navigations)
|
||||
storedSpecMeta: {}
|
||||
};
|
||||
|
||||
export const openapiSyncSlice = createSlice({
|
||||
@@ -33,6 +35,11 @@ export const openapiSyncSlice = createSlice({
|
||||
const { collectionUid } = action.payload;
|
||||
delete state.collectionUpdates[collectionUid];
|
||||
delete state.tabUiState[collectionUid];
|
||||
delete state.storedSpecMeta[collectionUid];
|
||||
},
|
||||
setStoredSpecMeta: (state, action) => {
|
||||
const { collectionUid, title, version, endpointCount } = action.payload;
|
||||
state.storedSpecMeta[collectionUid] = { title, version, endpointCount };
|
||||
},
|
||||
setPollingEnabled: (state, action) => {
|
||||
state.pollingEnabled = action.payload;
|
||||
@@ -116,7 +123,8 @@ export const {
|
||||
toggleRowExpanded,
|
||||
setLastPollTime,
|
||||
setReviewDecision,
|
||||
setReviewDecisions
|
||||
setReviewDecisions,
|
||||
setStoredSpecMeta
|
||||
} = openapiSyncSlice.actions;
|
||||
|
||||
// Lightweight thunk for polling — only checks hash, no deep comparison
|
||||
@@ -199,4 +207,9 @@ export const selectTabUiState = (collectionUid) => (state) => {
|
||||
return state.openapiSync?.tabUiState?.[collectionUid] || {};
|
||||
};
|
||||
|
||||
// Selector for stored spec metadata (title, version, endpointCount)
|
||||
export const selectStoredSpecMeta = (collectionUid) => (state) => {
|
||||
return state.openapiSync?.storedSpecMeta?.[collectionUid] || null;
|
||||
};
|
||||
|
||||
export default openapiSyncSlice.reducer;
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import {
|
||||
createWorkspace,
|
||||
removeWorkspace,
|
||||
setActiveWorkspace,
|
||||
updateWorkspace,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
updateWorkspaceLoadingState,
|
||||
setWorkspaceScratchCollection
|
||||
} from '../workspaces';
|
||||
import { showHomePage } from '../app';
|
||||
import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
|
||||
import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { clearCollectionState } from '../openapi-sync';
|
||||
import { updateGlobalEnvironments } from '../global-environments';
|
||||
import { addTab, focusTab } from '../tabs';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
@@ -53,20 +51,112 @@ const transformCollection = async (collection, type) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a workspace with a unique name under the given location
|
||||
* Creates a temporary workspace in Redux without touching the filesystem.
|
||||
* The workspace is only persisted to disk when the user confirms the name.
|
||||
*/
|
||||
export const createWorkspaceWithUniqueName = (location) => {
|
||||
return async (dispatch) => {
|
||||
const { uuid: generateUuid } = await import('utils/common');
|
||||
const tempUid = generateUuid();
|
||||
const name = await ipcRenderer?.invoke('renderer:find-unique-folder-name', 'Untitled Workspace', location) || 'Untitled Workspace';
|
||||
const folderName = sanitizeName(name);
|
||||
const result = await dispatch(createWorkspaceAction(name, folderName, location));
|
||||
if (result?.workspaceUid) {
|
||||
dispatch(updateWorkspace({ uid: result.workspaceUid, isNewlyCreated: true }));
|
||||
|
||||
dispatch(createWorkspace({
|
||||
uid: tempUid,
|
||||
name,
|
||||
pathname: null,
|
||||
collections: [],
|
||||
isCreating: true,
|
||||
creationLocation: location
|
||||
}));
|
||||
|
||||
dispatch(updateWorkspace({ uid: tempUid, isNewlyCreated: true }));
|
||||
await dispatch(switchWorkspace(tempUid));
|
||||
|
||||
return { workspaceUid: tempUid };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirms creation of a temporary workspace by persisting it to the filesystem.
|
||||
*/
|
||||
export const confirmWorkspaceCreation = (tempWorkspaceUid, workspaceName) => {
|
||||
return async (dispatch, getState) => {
|
||||
const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid);
|
||||
if (!tempWorkspace) {
|
||||
throw new Error('Temporary workspace not found');
|
||||
}
|
||||
|
||||
const location = tempWorkspace.creationLocation;
|
||||
if (!location) {
|
||||
throw new Error('Workspace creation location not found');
|
||||
}
|
||||
|
||||
const baseFolderName = sanitizeName(workspaceName);
|
||||
const folderName = await ipcRenderer?.invoke('renderer:find-unique-folder-name', baseFolderName, location) || baseFolderName;
|
||||
|
||||
const result = await ipcRenderer.invoke(
|
||||
'renderer:create-workspace',
|
||||
workspaceName,
|
||||
folderName,
|
||||
location
|
||||
);
|
||||
|
||||
const { workspaceUid: realUid, workspacePath, workspaceConfig } = result;
|
||||
|
||||
// Clean up the temp workspace's scratch collection after IPC succeeds
|
||||
// (doing it before would leave a broken state if the IPC call fails)
|
||||
if (tempWorkspace.scratchCollectionUid) {
|
||||
dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid }));
|
||||
}
|
||||
|
||||
// Remove the temporary workspace
|
||||
dispatch(removeWorkspace(tempWorkspaceUid));
|
||||
|
||||
// Ensure the real workspace exists in Redux (the workspace-opened event may or may not have fired yet)
|
||||
const existing = getState().workspaces.workspaces.find((w) => w.uid === realUid);
|
||||
if (!existing) {
|
||||
dispatch(createWorkspace({
|
||||
uid: realUid,
|
||||
pathname: workspacePath,
|
||||
...workspaceConfig
|
||||
}));
|
||||
}
|
||||
|
||||
dispatch(updateWorkspace({ uid: realUid, name: workspaceName }));
|
||||
|
||||
await dispatch(switchWorkspace(realUid));
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels creation of a temporary workspace, removing it from Redux.
|
||||
* Only switches to default workspace if the temp workspace was the active one.
|
||||
*/
|
||||
export const cancelWorkspaceCreation = (tempWorkspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid);
|
||||
if (!tempWorkspace) return;
|
||||
|
||||
// Clean up the scratch collection if one was mounted
|
||||
if (tempWorkspace.scratchCollectionUid) {
|
||||
dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid }));
|
||||
}
|
||||
|
||||
const wasActive = getState().workspaces.activeWorkspaceUid === tempWorkspaceUid;
|
||||
dispatch(removeWorkspace(tempWorkspaceUid));
|
||||
|
||||
// Only switch to default if the cancelled workspace was the active one
|
||||
if (wasActive) {
|
||||
const defaultWorkspace = getState().workspaces.workspaces.find((w) => w.type === 'default');
|
||||
if (defaultWorkspace) {
|
||||
await dispatch(switchWorkspace(defaultWorkspace.uid));
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useSelector } from 'react-redux';
|
||||
* Contains all available beta feature keys
|
||||
*/
|
||||
export const BETA_FEATURES = Object.freeze({
|
||||
NODE_VM: 'nodevm'
|
||||
NODE_VM: 'nodevm',
|
||||
OPENAPI_SYNC: 'openapi-sync'
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
|
||||
import store from 'providers/ReduxStore/index';
|
||||
import { reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { savePreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
const KEYBINDING_ACTIONS = [
|
||||
{
|
||||
actionName: 'closeTab',
|
||||
handler: () => {
|
||||
window.dispatchEvent(new CustomEvent('close-active-tab'));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
actionName: 'sendRequest',
|
||||
handler: (context) => {
|
||||
if (context?.props?.onRun) context.props.onRun();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
actionName: 'switchToPreviousTab',
|
||||
handler: () => {
|
||||
store.dispatch(switchTab({ direction: 'pageup' }));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
actionName: 'switchToNextTab',
|
||||
handler: () => {
|
||||
store.dispatch(switchTab({ direction: 'pagedown' }));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
actionName: 'moveTabLeft',
|
||||
handler: () => {
|
||||
store.dispatch(reorderTabs({ direction: -1 }));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
actionName: 'moveTabRight',
|
||||
handler: () => {
|
||||
store.dispatch(reorderTabs({ direction: 1 }));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
actionName: 'changeLayout',
|
||||
handler: () => {
|
||||
const state = store.getState();
|
||||
const preferences = state.app.preferences;
|
||||
const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences.layout,
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
store.dispatch(savePreferences(updatedPreferences));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
actionName: 'collapseSidebar',
|
||||
handler: () => {
|
||||
store.dispatch(toggleSidebarCollapse());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Converts user keybinding format to CodeMirror format
|
||||
* e.g., "command+bind+enter" -> "Cmd-Enter"
|
||||
* @param {string} combo - The keybinding combo string
|
||||
* @returns {string|null} CodeMirror formatted combo or null
|
||||
*/
|
||||
function convertToCodeMirrorFormat(combo) {
|
||||
if (!combo || typeof combo !== 'string') return null;
|
||||
|
||||
const normalized = combo
|
||||
.replace(/-/g, '+')
|
||||
.split('+')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.filter((p) => p.toLowerCase() !== 'bind')
|
||||
.join('+');
|
||||
|
||||
const parts = normalized.split('+').map((p) => p.trim()).filter(Boolean);
|
||||
|
||||
const out = parts.map((key) => {
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if (lower === 'command' || lower === 'cmd') return 'Cmd';
|
||||
if (lower === 'control' || lower === 'ctrl') return 'Ctrl';
|
||||
if (lower === 'option' || lower === 'alt') return 'Alt';
|
||||
if (lower === 'shift') return 'Shift';
|
||||
if (lower === 'mod') return 'Mod';
|
||||
|
||||
if (lower === 'enter' || lower === 'return') return 'Enter';
|
||||
if (lower === 'esc' || lower === 'escape') return 'Esc';
|
||||
if (lower === 'space') return 'Space';
|
||||
if (lower === 'tab') return 'Tab';
|
||||
if (lower === 'backspace') return 'Backspace';
|
||||
if (lower === 'delete' || lower === 'del') return 'Delete';
|
||||
if (lower === 'up') return 'Up';
|
||||
if (lower === 'down') return 'Down';
|
||||
if (lower === 'left') return 'Left';
|
||||
if (lower === 'right') return 'Right';
|
||||
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||
});
|
||||
|
||||
return out.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a consolidated CodeMirror keymap from all configured keybinding actions.
|
||||
* Uses CodeMirror.Pass for non-matching keys to allow default behavior.
|
||||
* @param {Object} context - Context object containing props and other editor context
|
||||
* @returns {Object} CodeMirror keymap object
|
||||
*/
|
||||
function buildKeymap(context) {
|
||||
let state;
|
||||
try {
|
||||
const reduxState = store.getState();
|
||||
state = reduxState;
|
||||
} catch (e) {
|
||||
state = { app: { preferences: {} } };
|
||||
}
|
||||
|
||||
const userKeyBindings = state.app.preferences?.keyBindings || {};
|
||||
|
||||
// Create a comprehensive keymap with CodeMirror.Pass as fallthrough
|
||||
// This allows non-matching keys to pass through to default CodeMirror behavior
|
||||
const keyMap = {
|
||||
name: 'singleLineEditor.custom',
|
||||
// CodeMirror.Pass tells CodeMirror to pass this key event to the next keymap
|
||||
// This is the key to making non-configured keys work normally
|
||||
fallthrough: CodeMirror.Pass
|
||||
};
|
||||
|
||||
// Build keymap entries for each configured action
|
||||
KEYBINDING_ACTIONS.forEach(({ actionName, handler }) => {
|
||||
const combos = getKeyBindingsForActionAllOS(actionName, userKeyBindings) || [];
|
||||
const cmCombos = combos
|
||||
.map((k) => convertToCodeMirrorFormat(k))
|
||||
.filter(Boolean);
|
||||
|
||||
if (cmCombos.length > 0) {
|
||||
cmCombos.forEach((cmKey) => {
|
||||
// Create handler that passes context as argument
|
||||
keyMap[cmKey] = () => handler(context);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return keyMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up keyboard shortcuts for a CodeMirror editor instance.
|
||||
* This enables custom keybindings with CodeMirror.Pass fallthrough support.
|
||||
* @param {Object} editor - The CodeMirror editor instance
|
||||
* @param {Object} context - Context object containing props and other editor context
|
||||
* @returns {Object} Cleanup function to remove the keymap
|
||||
*/
|
||||
function setupShortcuts(editor, context = {}) {
|
||||
if (!editor) {
|
||||
return () => { };
|
||||
}
|
||||
|
||||
let currentKeyMap = null;
|
||||
let unsubscribeStore = null;
|
||||
|
||||
/**
|
||||
* Apply the consolidated custom keymap to the CodeMirror editor
|
||||
*/
|
||||
const applyKeyMap = () => {
|
||||
if (!editor) return;
|
||||
|
||||
// Remove existing custom keymap if any
|
||||
if (currentKeyMap) {
|
||||
try {
|
||||
editor.removeKeyMap(currentKeyMap);
|
||||
} catch (e) {
|
||||
console.warn('[SingleLineEditor] Error removing keymap:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Build and apply new consolidated keymap
|
||||
currentKeyMap = buildKeymap(context);
|
||||
editor.addKeyMap(currentKeyMap);
|
||||
};
|
||||
|
||||
// Apply keymap on setup
|
||||
applyKeyMap();
|
||||
|
||||
// Subscribe to store changes to rebuild keymap when preferences change
|
||||
unsubscribeStore = store.subscribe(() => {
|
||||
applyKeyMap();
|
||||
});
|
||||
|
||||
/**
|
||||
* Cleanup function to remove the keymap and unsubscribe from store
|
||||
*/
|
||||
const cleanup = () => {
|
||||
if (unsubscribeStore) {
|
||||
unsubscribeStore();
|
||||
unsubscribeStore = null;
|
||||
}
|
||||
|
||||
if (editor && currentKeyMap) {
|
||||
try {
|
||||
editor.removeKeyMap(currentKeyMap);
|
||||
} catch (e) {
|
||||
console.warn('[SingleLineEditor] Error removing keymap on cleanup:', e);
|
||||
}
|
||||
currentKeyMap = null;
|
||||
}
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
export { setupShortcuts, buildKeymap, convertToCodeMirrorFormat, KEYBINDING_ACTIONS };
|
||||
@@ -902,7 +902,7 @@ describe('parseCurlCommand', () => {
|
||||
{ name: 'test', value: 'urlquery' },
|
||||
{ name: 'name', value: 'John%20Doe' },
|
||||
{ name: 'email', value: 'john@example.com' },
|
||||
{ name: 'hello', value: '' }
|
||||
{ name: 'hello', value: undefined }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,13 +53,13 @@
|
||||
"@usebruno/js": "0.12.0",
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chalk": "^3.0.0",
|
||||
"decomment": "^0.9.5",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "4.0.4",
|
||||
"fs-extra": "^10.1.0",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
|
||||
@@ -2,20 +2,16 @@ const qs = require('qs');
|
||||
const chalk = require('chalk');
|
||||
const decomment = require('decomment');
|
||||
const fs = require('fs');
|
||||
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
|
||||
const { forOwn, each, extend, get, compact } = require('lodash');
|
||||
const prepareRequest = require('./prepare-request');
|
||||
const interpolateVars = require('./interpolate-vars');
|
||||
const { interpolateString, interpolateObject } = require('./interpolate-string');
|
||||
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
|
||||
const { stripExtension } = require('../utils/filesystem');
|
||||
const { getOptions } = require('../utils/bru');
|
||||
const https = require('node:https');
|
||||
const http = require('node:http');
|
||||
const { HttpProxyAgent } = require('http-proxy-agent');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { makeAxiosInstance } = require('../utils/axios-instance');
|
||||
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
|
||||
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
|
||||
const { setupProxyAgents } = require('../utils/proxy-util');
|
||||
const path = require('path');
|
||||
const { parseDataFromResponse } = require('../utils/common');
|
||||
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
|
||||
@@ -23,10 +19,10 @@ const { createFormData } = require('../utils/form-data');
|
||||
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
|
||||
const { NtlmClient } = require('axios-ntlm');
|
||||
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
|
||||
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
|
||||
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
|
||||
const tokenStore = require('../store/tokenStore');
|
||||
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
|
||||
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
|
||||
|
||||
const onConsoleLog = (type, args) => {
|
||||
console[type](...args);
|
||||
@@ -429,90 +425,15 @@ const runSingleRequest = async function (
|
||||
}
|
||||
// else: collection proxy is disabled, proxyMode stays 'off'
|
||||
|
||||
// Prepare TLS options for agent caching
|
||||
const tlsOptions = {
|
||||
...httpsAgentRequestFields
|
||||
};
|
||||
|
||||
// HTTP agent options — separate from tlsOptions to avoid leaking TLS fields
|
||||
const httpAgentOptions = { keepAlive: true };
|
||||
|
||||
const parsedRequestUrl = new URL(request.url);
|
||||
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
|
||||
const hostname = parsedRequestUrl.hostname || null;
|
||||
|
||||
if (proxyMode === 'on') {
|
||||
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
|
||||
if (shouldProxy) {
|
||||
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
|
||||
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
|
||||
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
|
||||
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
|
||||
const socksEnabled = proxyProtocol.includes('socks');
|
||||
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
|
||||
let proxyUri;
|
||||
if (proxyAuthEnabled) {
|
||||
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
|
||||
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
|
||||
|
||||
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
|
||||
} else {
|
||||
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
|
||||
}
|
||||
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
|
||||
// (e.g., ca certs) even for plain HTTP requests
|
||||
const isHttpsProxy = proxyProtocol === 'https';
|
||||
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
|
||||
// Only set the agent needed for the request protocol
|
||||
if (socksEnabled) {
|
||||
if (isHttpsRequest) {
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
|
||||
} else {
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
|
||||
}
|
||||
} else {
|
||||
if (isHttpsRequest) {
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
|
||||
} else {
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
try {
|
||||
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
|
||||
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
const parsedHttpProxy = new URL(http_proxy);
|
||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system http_proxy');
|
||||
}
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system https_proxy');
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!request.httpAgent && !request.httpsAgent) {
|
||||
if (isHttpsRequest) {
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
|
||||
} else {
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
setupProxyAgents({
|
||||
requestConfig: request,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
systemProxyConfig: cachedSystemProxy,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
disableCache
|
||||
});
|
||||
|
||||
// set cookies if enabled
|
||||
if (!options.disableCookies) {
|
||||
@@ -559,18 +480,23 @@ const runSingleRequest = async function (
|
||||
// if `data` is of string type - return as-is (assumes already encoded)
|
||||
}
|
||||
|
||||
if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) {
|
||||
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
|
||||
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
|
||||
if (!isFormData(request?.data)) {
|
||||
request._originalMultipartData = request.data;
|
||||
request.collectionPath = collectionPath;
|
||||
let form = createFormData(request.data, collectionPath);
|
||||
request.data = form;
|
||||
|
||||
if (request?.headers?.['content-type'] !== 'multipart/form-data') {
|
||||
if (contentType !== 'multipart/form-data') {
|
||||
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
|
||||
const formHeaders = form.getHeaders();
|
||||
const ct = request.headers['content-type'];
|
||||
formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`;
|
||||
const existingBoundary = extractBoundaryFromContentType(contentType);
|
||||
if (existingBoundary) {
|
||||
formHeaders['content-type'] = contentType;
|
||||
} else {
|
||||
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
|
||||
}
|
||||
form.getHeaders = function () {
|
||||
return formHeaders;
|
||||
};
|
||||
@@ -688,7 +614,13 @@ const runSingleRequest = async function (
|
||||
let axiosInstance = makeAxiosInstance({
|
||||
requestMaxRedirects: requestMaxRedirects,
|
||||
disableCookies: options.disableCookies,
|
||||
followRedirects: followRedirects
|
||||
followRedirects: followRedirects,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
systemProxyConfig: cachedSystemProxy,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
disableCache
|
||||
});
|
||||
|
||||
if (request.ntlmConfig) {
|
||||
|
||||
@@ -2,6 +2,7 @@ const axios = require('axios');
|
||||
const { CLI_VERSION } = require('../constants');
|
||||
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
|
||||
const { createFormData } = require('./form-data');
|
||||
const { setupProxyAgents } = require('./proxy-util');
|
||||
|
||||
const redirectResponseCodes = [301, 302, 303, 307, 308];
|
||||
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
|
||||
@@ -71,7 +72,17 @@ const createRedirectConfig = (error, redirectUrl) => {
|
||||
* @see https://github.com/axios/axios/issues/695
|
||||
* @returns {axios.AxiosInstance}
|
||||
*/
|
||||
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedirects = true } = {}) {
|
||||
function makeAxiosInstance({
|
||||
requestMaxRedirects = 5,
|
||||
disableCookies,
|
||||
followRedirects = true,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
systemProxyConfig,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
disableCache
|
||||
} = {}) {
|
||||
let redirectCount = 0;
|
||||
|
||||
/** @type {axios.AxiosInstance} */
|
||||
@@ -167,6 +178,16 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedi
|
||||
|
||||
const requestConfig = createRedirectConfig(error, redirectUrl);
|
||||
|
||||
setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
systemProxyConfig,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
disableCache
|
||||
});
|
||||
|
||||
if (!disableCookies) {
|
||||
const cookieString = getCookieStringForUrl(redirectUrl);
|
||||
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
const parseUrl = require('url').parse;
|
||||
const { isEmpty } = require('lodash');
|
||||
const http = require('node:http');
|
||||
const https = require('node:https');
|
||||
const { isEmpty, get, isUndefined, isNull } = require('lodash');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { HttpProxyAgent } = require('http-proxy-agent');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
const { interpolateString } = require('../runner/interpolate-string');
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
ftp: 21,
|
||||
@@ -96,7 +102,103 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
}
|
||||
}
|
||||
|
||||
function setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode = 'off',
|
||||
proxyConfig,
|
||||
systemProxyConfig,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
disableCache = true
|
||||
}) {
|
||||
// Clear stale agents so we always recreate them for the current URL
|
||||
// (handles protocol switches, host changes, and proxy-bypass rules on redirects).
|
||||
delete requestConfig.httpAgent;
|
||||
delete requestConfig.httpsAgent;
|
||||
|
||||
const tlsOptions = { ...httpsAgentRequestFields };
|
||||
const httpAgentOptions = { keepAlive: true };
|
||||
|
||||
const parsedRequestUrl = new URL(requestConfig.url);
|
||||
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
|
||||
const hostname = parsedRequestUrl.hostname || null;
|
||||
|
||||
if (proxyMode === 'on') {
|
||||
const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
|
||||
if (shouldProxy) {
|
||||
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
|
||||
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
|
||||
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
|
||||
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
|
||||
const socksEnabled = proxyProtocol?.includes('socks') ?? false;
|
||||
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
|
||||
let proxyUri;
|
||||
if (proxyAuthEnabled) {
|
||||
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
|
||||
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
|
||||
|
||||
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
|
||||
} else {
|
||||
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
|
||||
}
|
||||
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
|
||||
// (e.g., ca certs) even for plain HTTP requests
|
||||
const isHttpsProxy = proxyProtocol === 'https';
|
||||
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
|
||||
// Only set the agent needed for the request protocol
|
||||
if (socksEnabled) {
|
||||
if (isHttpsRequest) {
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
|
||||
} else {
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
|
||||
}
|
||||
} else {
|
||||
if (isHttpsRequest) {
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
|
||||
} else {
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
try {
|
||||
const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
const parsedHttpProxy = new URL(http_proxy);
|
||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system http_proxy');
|
||||
}
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system https_proxy');
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
|
||||
if (isHttpsRequest) {
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
|
||||
} else {
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shouldUseProxy,
|
||||
PatchedHttpsProxyAgent
|
||||
PatchedHttpsProxyAgent,
|
||||
setupProxyAgents
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"babel-jest": "^29.7.0",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "4.0.4",
|
||||
"is-ip": "^5.0.1",
|
||||
"moment": "^2.29.4",
|
||||
"rollup": "3.29.5",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { buildFormUrlEncodedPayload, isFormData } from './form-data';
|
||||
import { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } from './form-data';
|
||||
import FormData from 'form-data';
|
||||
|
||||
describe('buildFormUrlEncodedPayload', () => {
|
||||
@@ -161,3 +161,51 @@ describe('isFormData', () => {
|
||||
expect(isFormData(formData)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBoundaryFromContentType', () => {
|
||||
it('should extract boundary from Content-Type header', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should extract boundary with dashes', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW')).toBe('----WebKitFormBoundary7MA4YWxkTrZu0gW');
|
||||
});
|
||||
|
||||
it('should extract boundary case-insensitively', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; BOUNDARY=my-boundary')).toBe('my-boundary');
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; Boundary=my-boundary')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should extract boundary when other params exist', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary=my-boundary')).toBe('my-boundary');
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary; charset=utf-8')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should return null when no boundary exists', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed')).toBeNull();
|
||||
expect(extractBoundaryFromContentType('application/json')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-string input', () => {
|
||||
expect(extractBoundaryFromContentType(null)).toBeNull();
|
||||
expect(extractBoundaryFromContentType(undefined)).toBeNull();
|
||||
expect(extractBoundaryFromContentType(123)).toBeNull();
|
||||
expect(extractBoundaryFromContentType({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(extractBoundaryFromContentType('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract boundary from quoted value', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my-boundary"')).toBe('my-boundary');
|
||||
});
|
||||
|
||||
it('should extract quoted boundary with spaces', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my boundary value"')).toBe('my boundary value');
|
||||
});
|
||||
|
||||
it('should extract quoted boundary when other params exist', () => {
|
||||
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary="my-boundary"')).toBe('my-boundary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,3 +43,16 @@ export const isFormData = (obj: unknown): boolean => {
|
||||
// todo: checking constructor.name can produce false positives for objects that have a constructor.name property set to 'FormData', but this is rare.
|
||||
return obj?.constructor?.name === 'FormData';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts boundary parameter from a Content-Type header value.
|
||||
* @param contentType - The Content-Type header value (e.g., "multipart/mixed; boundary=my-boundary")
|
||||
* @returns The boundary value if found, or null if not present
|
||||
*/
|
||||
export const extractBoundaryFromContentType = (contentType: unknown): string | null => {
|
||||
if (typeof contentType !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = contentType.match(/boundary="([^"]+)"|boundary=([^;\s]+)/i);
|
||||
return match ? (match[1] || match[2]) : null;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ export {
|
||||
|
||||
export {
|
||||
buildFormUrlEncodedPayload,
|
||||
isFormData
|
||||
isFormData,
|
||||
extractBoundaryFromContentType
|
||||
} from './form-data';
|
||||
|
||||
export {
|
||||
|
||||
@@ -50,6 +50,18 @@ describe('encodeUrl', () => {
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle query parameters without values (no = sign)', () => {
|
||||
const url = 'https://example.com/api?flag&age=25&verbose';
|
||||
const expected = 'https://example.com/api?flag&age=25&verbose';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle mixed empty-value and no-value parameters', () => {
|
||||
const url = 'https://example.com/api?seat=&table=2&flag';
|
||||
const expected = 'https://example.com/api?seat=&table=2&flag';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should encode query parameters with pipe operator', () => {
|
||||
const url = 'https://example.com/api?filter=status|active&sort=name|asc&tags=frontend|backend|api';
|
||||
const expected = 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc&tags=frontend%7Cbackend%7Capi';
|
||||
@@ -159,7 +171,7 @@ describe('parseQueryParams', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle query parameters with empty values', () => {
|
||||
it('should handle query parameters with empty values (has = sign)', () => {
|
||||
const queryString = 'name=&age=25&active=';
|
||||
const result = parseQueryParams(queryString);
|
||||
expect(result).toEqual([
|
||||
@@ -169,6 +181,16 @@ describe('parseQueryParams', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle query parameters without values (no = sign)', () => {
|
||||
const queryString = 'flag&age=25&verbose';
|
||||
const result = parseQueryParams(queryString);
|
||||
expect(result).toEqual([
|
||||
{ name: 'flag', value: undefined },
|
||||
{ name: 'age', value: '25' },
|
||||
{ name: 'verbose', value: undefined }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract query parameters with pipe operator', () => {
|
||||
const queryString = 'filter=status|active&sort=name|asc&tags=frontend|backend';
|
||||
const result = parseQueryParams(queryString);
|
||||
@@ -218,4 +240,23 @@ describe('buildQueryString', () => {
|
||||
const result = buildQueryString(params, { encode: false });
|
||||
expect(result).toBe('filter=status|active&sort=name|asc');
|
||||
});
|
||||
|
||||
it('should omit = for params with undefined value', () => {
|
||||
const params = [
|
||||
{ name: 'flag', value: undefined },
|
||||
{ name: 'age', value: '25' },
|
||||
{ name: 'verbose' }
|
||||
];
|
||||
const result = buildQueryString(params);
|
||||
expect(result).toBe('flag&age=25&verbose');
|
||||
});
|
||||
|
||||
it('should include = for params with empty string value', () => {
|
||||
const params = [
|
||||
{ name: 'seat', value: '' },
|
||||
{ name: 'table', value: '2' }
|
||||
];
|
||||
const result = buildQueryString(params);
|
||||
expect(result).toBe('seat=&table=2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,12 @@ function buildQueryString(paramsArray: QueryParam[], { encode = false }: BuildQu
|
||||
.filter(({ name }) => typeof name === 'string' && name.trim().length > 0)
|
||||
.map(({ name, value }) => {
|
||||
const finalName = encode ? encodeURIComponent(name) : name;
|
||||
const finalValue = encode ? encodeURIComponent(value ?? '') : (value ?? '');
|
||||
|
||||
if (value === undefined) {
|
||||
return finalName;
|
||||
}
|
||||
|
||||
const finalValue = encode ? encodeURIComponent(value) : value;
|
||||
return `${finalName}=${finalValue}`;
|
||||
})
|
||||
.join('&');
|
||||
@@ -39,9 +43,13 @@ function parseQueryParams(query: string, { decode = false }: ExtractQueryParamsO
|
||||
return null;
|
||||
}
|
||||
|
||||
// Distinguish between ?param (no '=' at all) and ?param= (has '=' with empty value)
|
||||
const hasEqualsSign = pair.includes('=');
|
||||
const value = hasEqualsSign ? (decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')) : undefined;
|
||||
|
||||
return {
|
||||
name: decode ? decodeURIComponent(name) : name,
|
||||
value: decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')
|
||||
value
|
||||
};
|
||||
}).filter((param): param is NonNullable<typeof param> => param !== null);
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export const validateSchema = (collection = {}) => {
|
||||
return collection;
|
||||
} catch (err) {
|
||||
console.log('Error validating schema', err);
|
||||
throw new Error('The Collection has an invalid schema');
|
||||
throw new Error(`The Collection has an invalid schema: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -73,6 +73,55 @@ const isItemAFolder = (item) => {
|
||||
return !item.request;
|
||||
};
|
||||
|
||||
/**
|
||||
* Postman allows non-string values (e.g. numbers) in fields like header values,
|
||||
* query param values, etc. Bruno expects these to be strings.
|
||||
* Converts non-null/non-empty values to strings, returns fallback for null/undefined/empty.
|
||||
*/
|
||||
const ensureString = (value, fallback = '') => {
|
||||
if (value == null || value === '') return fallback;
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Postman's schema allows headers as strings in the format "Key: Value".
|
||||
* This parses a single string header into an object.
|
||||
*/
|
||||
const parseStringHeader = (header) => {
|
||||
const colonIndex = header.indexOf(':');
|
||||
if (colonIndex === -1) return { key: header.trim(), value: '' };
|
||||
return {
|
||||
key: header.substring(0, colonIndex).trim(),
|
||||
value: header.substring(colonIndex + 1).trim()
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Postman's schema allows the header field to be:
|
||||
* 1. An array of objects (most common)
|
||||
* 2. An array with mixed string and object items
|
||||
* 3. A single concatenated string (e.g. "Key1: Value1\r\nKey2: Value2")
|
||||
* 4. null
|
||||
*
|
||||
* This normalizes all forms into an array of header objects.
|
||||
*/
|
||||
const normalizeHeaders = (headers) => {
|
||||
if (!headers) return [];
|
||||
|
||||
if (typeof headers === 'string') {
|
||||
return headers.split(/\r?\n/).filter(Boolean).map(parseStringHeader);
|
||||
}
|
||||
|
||||
if (!Array.isArray(headers)) return [];
|
||||
|
||||
return headers.map((header) => {
|
||||
if (typeof header === 'string') return parseStringHeader(header);
|
||||
return header;
|
||||
});
|
||||
};
|
||||
|
||||
const convertV21Auth = (array) => {
|
||||
return array.reduce((accumulator, currentValue) => {
|
||||
accumulator[currentValue.key] = currentValue.value;
|
||||
@@ -159,7 +208,7 @@ const importCollectionLevelVariables = (variables, requestObject) => {
|
||||
const vars = variables.filter((v) => !(v.key == null && v.value == null)).map((v) => ({
|
||||
uid: uuid(),
|
||||
name: (v.key ?? '').replace(invalidVariableCharacterRegex, '_'),
|
||||
value: v.value ?? '',
|
||||
value: v.value == null ? '' : typeof v.value === 'string' ? v.value : JSON.stringify(v.value),
|
||||
enabled: true
|
||||
}));
|
||||
|
||||
@@ -194,40 +243,40 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
|
||||
switch (auth.type) {
|
||||
case AUTH_TYPES.BASIC:
|
||||
requestObject.auth.basic = {
|
||||
username: authValues.username || '',
|
||||
password: authValues.password || ''
|
||||
username: ensureString(authValues.username),
|
||||
password: ensureString(authValues.password)
|
||||
};
|
||||
break;
|
||||
case AUTH_TYPES.BEARER:
|
||||
requestObject.auth.bearer = {
|
||||
token: authValues.token || ''
|
||||
token: ensureString(authValues.token)
|
||||
};
|
||||
break;
|
||||
case AUTH_TYPES.AWSV4:
|
||||
requestObject.auth.awsv4 = {
|
||||
accessKeyId: authValues.accessKey || '',
|
||||
secretAccessKey: authValues.secretKey || '',
|
||||
sessionToken: authValues.sessionToken || '',
|
||||
service: authValues.service || '',
|
||||
region: authValues.region || '',
|
||||
accessKeyId: ensureString(authValues.accessKey),
|
||||
secretAccessKey: ensureString(authValues.secretKey),
|
||||
sessionToken: ensureString(authValues.sessionToken),
|
||||
service: ensureString(authValues.service),
|
||||
region: ensureString(authValues.region),
|
||||
profileName: ''
|
||||
};
|
||||
break;
|
||||
case AUTH_TYPES.APIKEY:
|
||||
requestObject.auth.apikey = {
|
||||
key: authValues.key || '',
|
||||
value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it,
|
||||
key: ensureString(authValues.key),
|
||||
value: ensureString(authValues.value),
|
||||
placement: 'header' // By default we are placing the apikey values in headers!
|
||||
};
|
||||
break;
|
||||
case AUTH_TYPES.DIGEST:
|
||||
requestObject.auth.digest = {
|
||||
username: authValues.username || '',
|
||||
password: authValues.password || ''
|
||||
username: ensureString(authValues.username),
|
||||
password: ensureString(authValues.password)
|
||||
};
|
||||
break;
|
||||
case AUTH_TYPES.OAUTH2:
|
||||
const findValueUsingKey = (key) => authValues[key] || '';
|
||||
case AUTH_TYPES.OAUTH2: {
|
||||
const findValueUsingKey = (key) => ensureString(authValues[key]);
|
||||
|
||||
// Maps Postman's grant_type to the Bruno's grantType string expected in the target object
|
||||
const oauth2GrantTypeMaps = {
|
||||
@@ -286,6 +335,7 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
requestObject.auth.mode = AUTH_TYPES.NONE;
|
||||
console.warn('Unexpected auth.type:', auth.type, '- Mode set, but no specific config generated.');
|
||||
@@ -470,12 +520,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
const isFile = param.type === 'file' || (param.type === 'default' && param.src);
|
||||
const value = isFile
|
||||
? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : [])
|
||||
: (Array.isArray(param.value) ? param.value.join('') : param.value ?? '');
|
||||
: (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value));
|
||||
|
||||
brunoRequestItem.request.body.multipartForm.push({
|
||||
uid: uuid(),
|
||||
type: isFile ? 'file' : 'text',
|
||||
name: param.key ?? '',
|
||||
name: ensureString(param.key),
|
||||
value,
|
||||
description: transformDescription(param.description),
|
||||
enabled: !param.disabled,
|
||||
@@ -490,8 +540,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
if (param.key == null && param.value == null) return;
|
||||
brunoRequestItem.request.body.formUrlEncoded.push({
|
||||
uid: uuid(),
|
||||
name: param.key ?? '',
|
||||
value: param.value ?? '',
|
||||
name: ensureString(param.key),
|
||||
value: ensureString(param.value),
|
||||
description: transformDescription(param.description),
|
||||
enabled: !param.disabled
|
||||
});
|
||||
@@ -522,12 +572,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
|
||||
}
|
||||
|
||||
each(i.request.header, (header) => {
|
||||
each(normalizeHeaders(i.request.header), (header) => {
|
||||
if (header.key == null && header.value == null) return;
|
||||
brunoRequestItem.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: header.key ?? '',
|
||||
value: header.value ?? '',
|
||||
name: ensureString(header.key),
|
||||
value: ensureString(header.value),
|
||||
description: transformDescription(header.description),
|
||||
enabled: !header.disabled
|
||||
});
|
||||
@@ -542,8 +592,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
}
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.key ?? '',
|
||||
value: param.value ?? '',
|
||||
name: ensureString(param.key),
|
||||
value: ensureString(param.value),
|
||||
description: transformDescription(param.description),
|
||||
type: 'query',
|
||||
enabled: !param.disabled
|
||||
@@ -558,8 +608,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.key,
|
||||
value: param.value ?? '',
|
||||
name: ensureString(param.key),
|
||||
value: ensureString(param.value),
|
||||
description: transformDescription(param.description),
|
||||
type: 'path',
|
||||
enabled: true
|
||||
@@ -611,13 +661,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
};
|
||||
|
||||
// Convert original request headers
|
||||
if (originalRequest.header && Array.isArray(originalRequest.header)) {
|
||||
originalRequest.header.forEach((header) => {
|
||||
if (originalRequest.header) {
|
||||
normalizeHeaders(originalRequest.header).forEach((header) => {
|
||||
if (header.key == null && header.value == null) return;
|
||||
example.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: header.key ?? '',
|
||||
value: header.value ?? '',
|
||||
name: ensureString(header.key),
|
||||
value: ensureString(header.value),
|
||||
description: transformDescription(header.description),
|
||||
enabled: !header.disabled
|
||||
});
|
||||
@@ -632,8 +682,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
}
|
||||
example.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.key ?? '',
|
||||
value: param.value ?? '',
|
||||
name: ensureString(param.key),
|
||||
value: ensureString(param.value),
|
||||
description: transformDescription(param.description),
|
||||
type: 'query',
|
||||
enabled: !param.disabled
|
||||
@@ -646,8 +696,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
if (!param.key) return;
|
||||
example.request.params.push({
|
||||
uid: uuid(),
|
||||
name: param.key,
|
||||
value: param.value ?? '',
|
||||
name: ensureString(param.key),
|
||||
value: ensureString(param.value),
|
||||
description: transformDescription(param.description),
|
||||
type: 'path',
|
||||
enabled: true
|
||||
@@ -666,12 +716,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
const isFile = param.type === 'file' || (param.type === 'default' && param.src);
|
||||
const value = isFile
|
||||
? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : [])
|
||||
: (Array.isArray(param.value) ? param.value.join('') : param.value ?? '');
|
||||
: (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value));
|
||||
|
||||
example.request.body.multipartForm.push({
|
||||
uid: uuid(),
|
||||
type: isFile ? 'file' : 'text',
|
||||
name: param.key ?? '',
|
||||
name: ensureString(param.key),
|
||||
value,
|
||||
description: transformDescription(param.description),
|
||||
enabled: !param.disabled,
|
||||
@@ -686,8 +736,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
if (param.key == null && param.value == null) return;
|
||||
example.request.body.formUrlEncoded.push({
|
||||
uid: uuid(),
|
||||
name: param.key ?? '',
|
||||
value: param.value ?? '',
|
||||
name: ensureString(param.key),
|
||||
value: ensureString(param.value),
|
||||
description: transformDescription(param.description),
|
||||
enabled: !param.disabled
|
||||
});
|
||||
@@ -712,13 +762,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
}
|
||||
|
||||
// Convert response headers
|
||||
if (response.header && Array.isArray(response.header)) {
|
||||
response.header.forEach((header) => {
|
||||
if (response.header) {
|
||||
normalizeHeaders(response.header).forEach((header) => {
|
||||
if (header.key == null && header.value == null) return;
|
||||
example.response.headers.push({
|
||||
uid: uuid(),
|
||||
name: header.key ?? '',
|
||||
value: header.value ?? '',
|
||||
name: ensureString(header.key),
|
||||
value: ensureString(header.value),
|
||||
description: transformDescription(header.description),
|
||||
enabled: true
|
||||
});
|
||||
@@ -736,8 +786,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
|
||||
const searchLanguageByHeader = (headers) => {
|
||||
let contentType;
|
||||
each(headers, (header) => {
|
||||
if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
|
||||
each(normalizeHeaders(headers), (header) => {
|
||||
if (header.key?.toLowerCase() === 'content-type' && !header.disabled) {
|
||||
if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
|
||||
contentType = 'json';
|
||||
} else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
|
||||
@@ -750,14 +800,14 @@ const searchLanguageByHeader = (headers) => {
|
||||
};
|
||||
|
||||
const getBodyTypeFromContentTypeHeader = (headers) => {
|
||||
// Check if headers is null, undefined, or not an array
|
||||
if (!headers || !Array.isArray(headers)) {
|
||||
const normalizedHeaders = normalizeHeaders(headers);
|
||||
if (!normalizedHeaders.length) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
const contentTypeHeader = headers.find((header) => header.key.toLowerCase() === 'content-type');
|
||||
if (contentTypeHeader) {
|
||||
const contentType = contentTypeHeader.value?.toLowerCase();
|
||||
const contentTypeHeader = normalizedHeaders.find((header) => header.key?.toLowerCase() === 'content-type');
|
||||
if (contentTypeHeader && typeof contentTypeHeader.value === 'string') {
|
||||
const contentType = contentTypeHeader.value.toLowerCase();
|
||||
if (contentType?.includes('application/json')) {
|
||||
return 'json';
|
||||
} else if (contentType?.includes('application/xml') || contentType?.includes('text/xml')) {
|
||||
|
||||
@@ -238,6 +238,43 @@ describe('postman-collection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert non-string variable values to strings', async () => {
|
||||
const collectionWithNonStringVars = {
|
||||
info: {
|
||||
name: 'Non-String Variable Demo',
|
||||
_postman_id: 'abcd1234-5678-90ef-ghij-1234567890ab',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
variable: [
|
||||
{ key: 'timeout', value: 5000 },
|
||||
{ key: 'enabled', value: true },
|
||||
{ key: 'user', value: { id: 1, name: 'Alice' } }
|
||||
],
|
||||
item: [
|
||||
{
|
||||
name: 'Sample Request',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: {
|
||||
raw: 'https://postman-echo.com/get',
|
||||
protocol: 'https',
|
||||
host: ['postman-echo', 'com'],
|
||||
path: ['get']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithNonStringVars);
|
||||
const vars = brunoCollection.root.request.vars.req;
|
||||
|
||||
expect(vars).toHaveLength(3);
|
||||
expect(vars[0]).toMatchObject({ name: 'timeout', value: '5000' });
|
||||
expect(vars[1]).toMatchObject({ name: 'enabled', value: 'true' });
|
||||
expect(vars[2]).toMatchObject({ name: 'user', value: '{"id":1,"name":"Alice"}' });
|
||||
});
|
||||
|
||||
it('should handle empty variables', async () => {
|
||||
const collectionWithEmptyVars = {
|
||||
info: {
|
||||
@@ -769,6 +806,337 @@ describe('postman-collection', () => {
|
||||
expect(params[2].value).toBe('');
|
||||
expect(params[2].type).toBe('query');
|
||||
});
|
||||
|
||||
it('should convert numeric values to strings in headers, params, and body fields', async () => {
|
||||
const collectionWithNumericValues = {
|
||||
info: {
|
||||
_postman_id: 'test-numeric-values',
|
||||
name: 'collection with numeric values',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'request with numeric values',
|
||||
request: {
|
||||
method: 'POST',
|
||||
header: [
|
||||
{ key: 'X-Account-Id', value: 0 },
|
||||
{ key: 'X-Retry-Count', value: 3 }
|
||||
],
|
||||
url: {
|
||||
raw: 'https://example.com/api/:accountId',
|
||||
protocol: 'https',
|
||||
host: ['example', 'com'],
|
||||
path: ['api', ':accountId'],
|
||||
query: [
|
||||
{ key: 'limit', value: 100 },
|
||||
{ key: 'offset', value: 0 }
|
||||
],
|
||||
variable: [
|
||||
{ key: 'accountId', value: 0 }
|
||||
]
|
||||
},
|
||||
body: {
|
||||
mode: 'urlencoded',
|
||||
urlencoded: [
|
||||
{ key: 'timeout', value: 5000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'request with numeric multipart form values',
|
||||
request: {
|
||||
method: 'POST',
|
||||
header: [],
|
||||
url: { raw: 'https://example.com/upload' },
|
||||
body: {
|
||||
mode: 'formdata',
|
||||
formdata: [
|
||||
{ key: 'retries', value: 3, type: 'text' },
|
||||
{ key: 'priority', value: 0, type: 'text' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithNumericValues);
|
||||
const item = brunoCollection.items[0];
|
||||
|
||||
// Headers should have string values
|
||||
expect(item.request.headers[0].value).toBe('0');
|
||||
expect(item.request.headers[1].value).toBe('3');
|
||||
|
||||
// Query params should have string values
|
||||
const queryParams = item.request.params.filter((p) => p.type === 'query');
|
||||
expect(queryParams[0].value).toBe('100');
|
||||
expect(queryParams[1].value).toBe('0');
|
||||
|
||||
// Path params should have string values
|
||||
const pathParams = item.request.params.filter((p) => p.type === 'path');
|
||||
expect(pathParams[0].value).toBe('0');
|
||||
|
||||
// Form URL-encoded should have string values
|
||||
expect(item.request.body.formUrlEncoded[0].value).toBe('5000');
|
||||
|
||||
// Multipart form should have string values
|
||||
const multipartItem = brunoCollection.items[1];
|
||||
expect(multipartItem.request.body.multipartForm[0].value).toBe('3');
|
||||
expect(multipartItem.request.body.multipartForm[1].value).toBe('0');
|
||||
});
|
||||
|
||||
it('should convert numeric values to strings in example request and response fields', async () => {
|
||||
const collectionWithNumericExamples = {
|
||||
info: {
|
||||
_postman_id: 'test-numeric-examples',
|
||||
name: 'collection with numeric example values',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'request with numeric example',
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: [],
|
||||
url: { raw: 'https://example.com/api' }
|
||||
},
|
||||
response: [
|
||||
{
|
||||
name: 'Example with numerics',
|
||||
originalRequest: {
|
||||
method: 'GET',
|
||||
header: [
|
||||
{ key: 'X-Account-Id', value: 42 }
|
||||
],
|
||||
url: {
|
||||
raw: 'https://example.com/api/:id?page=1',
|
||||
protocol: 'https',
|
||||
host: ['example', 'com'],
|
||||
path: ['api', ':id'],
|
||||
query: [
|
||||
{ key: 'page', value: 1 }
|
||||
],
|
||||
variable: [
|
||||
{ key: 'id', value: 99 }
|
||||
]
|
||||
},
|
||||
body: {
|
||||
mode: 'urlencoded',
|
||||
urlencoded: [
|
||||
{ key: 'retries', value: 3 }
|
||||
]
|
||||
}
|
||||
},
|
||||
status: 'OK',
|
||||
code: 200,
|
||||
header: [
|
||||
{ key: 'X-RateLimit-Remaining', value: 0 }
|
||||
],
|
||||
body: '{"ok": true}'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithNumericExamples);
|
||||
const example = brunoCollection.items[0].examples[0];
|
||||
|
||||
// Example request headers
|
||||
expect(example.request.headers[0].value).toBe('42');
|
||||
|
||||
// Example request query params
|
||||
const queryParams = example.request.params.filter((p) => p.type === 'query');
|
||||
expect(queryParams[0].value).toBe('1');
|
||||
|
||||
// Example request path params
|
||||
const pathParams = example.request.params.filter((p) => p.type === 'path');
|
||||
expect(pathParams[0].value).toBe('99');
|
||||
|
||||
// Example request form URL-encoded
|
||||
expect(example.request.body.formUrlEncoded[0].value).toBe('3');
|
||||
|
||||
// Example response headers
|
||||
expect(example.response.headers[0].value).toBe('0');
|
||||
});
|
||||
|
||||
it('should convert numeric auth values to strings (array-backed v2.1 format)', async () => {
|
||||
const collectionWithNumericAuth = {
|
||||
info: {
|
||||
_postman_id: 'test-numeric-auth',
|
||||
name: 'collection with numeric auth values',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'request with numeric bearer token',
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: [],
|
||||
url: { raw: 'https://example.com/api' },
|
||||
auth: {
|
||||
type: 'bearer',
|
||||
bearer: [
|
||||
{ key: 'token', value: 123 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'request with numeric apikey values',
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: [],
|
||||
url: { raw: 'https://example.com/api' },
|
||||
auth: {
|
||||
type: 'apikey',
|
||||
apikey: [
|
||||
{ key: 'key', value: 456 },
|
||||
{ key: 'value', value: 789 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithNumericAuth);
|
||||
|
||||
// Bearer token should be stringified
|
||||
expect(brunoCollection.items[0].request.auth.mode).toBe('bearer');
|
||||
expect(brunoCollection.items[0].request.auth.bearer.token).toBe('123');
|
||||
|
||||
// API key fields should be stringified
|
||||
expect(brunoCollection.items[1].request.auth.mode).toBe('apikey');
|
||||
expect(brunoCollection.items[1].request.auth.apikey.key).toBe('456');
|
||||
expect(brunoCollection.items[1].request.auth.apikey.value).toBe('789');
|
||||
});
|
||||
|
||||
it('should convert numeric auth values to strings (object-backed format)', async () => {
|
||||
const collectionWithObjectAuth = {
|
||||
info: {
|
||||
_postman_id: 'test-object-auth',
|
||||
name: 'collection with object-backed auth',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'request with object-backed basic auth',
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: [],
|
||||
url: { raw: 'https://example.com/api' },
|
||||
auth: {
|
||||
type: 'basic',
|
||||
basic: {
|
||||
username: 12345,
|
||||
password: 67890
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithObjectAuth);
|
||||
|
||||
expect(brunoCollection.items[0].request.auth.mode).toBe('basic');
|
||||
expect(brunoCollection.items[0].request.auth.basic.username).toBe('12345');
|
||||
expect(brunoCollection.items[0].request.auth.basic.password).toBe('67890');
|
||||
});
|
||||
|
||||
it('should parse string headers in request header arrays', async () => {
|
||||
const collectionWithStringHeaders = {
|
||||
info: {
|
||||
_postman_id: 'test-string-headers',
|
||||
name: 'collection with string headers',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'request with string headers',
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: [
|
||||
'Content-Type: application/json',
|
||||
{ key: 'X-Custom', value: 'test' },
|
||||
'Authorization: Bearer token123'
|
||||
],
|
||||
url: { raw: 'https://example.com/api' }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithStringHeaders);
|
||||
const headers = brunoCollection.items[0].request.headers;
|
||||
|
||||
expect(headers).toHaveLength(3);
|
||||
expect(headers[0].name).toBe('Content-Type');
|
||||
expect(headers[0].value).toBe('application/json');
|
||||
expect(headers[1].name).toBe('X-Custom');
|
||||
expect(headers[1].value).toBe('test');
|
||||
expect(headers[2].name).toBe('Authorization');
|
||||
expect(headers[2].value).toBe('Bearer token123');
|
||||
});
|
||||
|
||||
it('should parse a single concatenated string as the header field', async () => {
|
||||
const collectionWithConcatenatedHeaders = {
|
||||
info: {
|
||||
_postman_id: 'test-concat-headers',
|
||||
name: 'collection with concatenated header string',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'request with concatenated header',
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: 'Content-Type: application/json\r\nHost: example.com',
|
||||
url: { raw: 'https://example.com/api' }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithConcatenatedHeaders);
|
||||
const headers = brunoCollection.items[0].request.headers;
|
||||
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0].name).toBe('Content-Type');
|
||||
expect(headers[0].value).toBe('application/json');
|
||||
expect(headers[1].name).toBe('Host');
|
||||
expect(headers[1].value).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should handle string headers with no value', async () => {
|
||||
const collectionWithNoValueHeader = {
|
||||
info: {
|
||||
_postman_id: 'test-no-value-header',
|
||||
name: 'collection with no-value string header',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'request with no-value header',
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: ['X-No-Value'],
|
||||
url: { raw: 'https://example.com/api' }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithNoValueHeader);
|
||||
const headers = brunoCollection.items[0].request.headers;
|
||||
|
||||
expect(headers).toHaveLength(1);
|
||||
expect(headers[0].name).toBe('X-No-Value');
|
||||
expect(headers[0].value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// Simple Collection (postman)
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"about-window": "^1.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chokidar": "^3.5.3",
|
||||
@@ -58,7 +58,7 @@
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-util": "^0.17.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "4.0.4",
|
||||
"fs-extra": "^10.1.0",
|
||||
"graphql": "^16.6.0",
|
||||
"hexy": "^0.3.5",
|
||||
|
||||
@@ -141,6 +141,16 @@ class ApiSpecWatcher {
|
||||
delete this.watcherWorkspaces[watchPath];
|
||||
}
|
||||
}
|
||||
|
||||
closeAllWatchers() {
|
||||
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
|
||||
try {
|
||||
watcher?.close();
|
||||
} catch (err) {}
|
||||
}
|
||||
this.watchers = {};
|
||||
this.watcherWorkspaces = {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiSpecWatcher;
|
||||
|
||||
@@ -552,58 +552,84 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
|
||||
};
|
||||
|
||||
const unlink = (win, pathname, collectionUid, collectionPath) => {
|
||||
console.log(`watcher unlink: ${pathname}`);
|
||||
|
||||
if (isEnvironmentsFolder(pathname, collectionPath)) {
|
||||
return unlinkEnvironmentFile(win, pathname, collectionUid);
|
||||
}
|
||||
|
||||
const format = getCollectionFormat(collectionPath);
|
||||
if (hasRequestExtension(pathname, format)) {
|
||||
const basename = path.basename(pathname);
|
||||
const dirname = path.dirname(pathname);
|
||||
|
||||
if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
|
||||
try {
|
||||
if (!fs.existsSync(collectionPath)) {
|
||||
return;
|
||||
}
|
||||
console.log(`watcher unlink: ${pathname}`);
|
||||
|
||||
const file = {
|
||||
meta: {
|
||||
collectionUid,
|
||||
pathname,
|
||||
name: basename
|
||||
if (isEnvironmentsFolder(pathname, collectionPath)) {
|
||||
return unlinkEnvironmentFile(win, pathname, collectionUid);
|
||||
}
|
||||
|
||||
let format;
|
||||
try {
|
||||
format = getCollectionFormat(collectionPath);
|
||||
} catch (error) {
|
||||
console.error(`Error getting collection format for: ${collectionPath}`, error);
|
||||
return;
|
||||
}
|
||||
if (hasRequestExtension(pathname, format)) {
|
||||
const basename = path.basename(pathname);
|
||||
const dirname = path.dirname(pathname);
|
||||
|
||||
if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
win.webContents.send('main:collection-tree-updated', 'unlink', file);
|
||||
|
||||
const file = {
|
||||
meta: {
|
||||
collectionUid,
|
||||
pathname,
|
||||
name: basename
|
||||
}
|
||||
};
|
||||
win.webContents.send('main:collection-tree-updated', 'unlink', file);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing unlink event for: ${pathname}`, err);
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
|
||||
const envDirectory = path.join(collectionPath, 'environments');
|
||||
|
||||
if (path.normalize(pathname) === path.normalize(envDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const format = getCollectionFormat(collectionPath);
|
||||
const folderFilePath = path.join(pathname, `folder.${format}`);
|
||||
|
||||
let name = path.basename(pathname);
|
||||
|
||||
if (fs.existsSync(folderFilePath)) {
|
||||
let folderFileContent = fs.readFileSync(folderFilePath, 'utf8');
|
||||
let folderData = await parseFolder(folderFileContent, { format });
|
||||
name = folderData?.meta?.name || name;
|
||||
}
|
||||
|
||||
const directory = {
|
||||
meta: {
|
||||
collectionUid,
|
||||
pathname,
|
||||
name
|
||||
try {
|
||||
if (!fs.existsSync(collectionPath)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
|
||||
const envDirectory = path.join(collectionPath, 'environments');
|
||||
|
||||
if (path.normalize(pathname) === path.normalize(envDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let format;
|
||||
try {
|
||||
format = getCollectionFormat(collectionPath);
|
||||
} catch (error) {
|
||||
console.error(`Error getting collection format for: ${collectionPath}`, error);
|
||||
return;
|
||||
}
|
||||
const folderFilePath = path.join(pathname, `folder.${format}`);
|
||||
|
||||
let name = path.basename(pathname);
|
||||
|
||||
if (fs.existsSync(folderFilePath)) {
|
||||
let folderFileContent = fs.readFileSync(folderFilePath, 'utf8');
|
||||
let folderData = await parseFolder(folderFileContent, { format });
|
||||
name = folderData?.meta?.name || name;
|
||||
}
|
||||
|
||||
const directory = {
|
||||
meta: {
|
||||
collectionUid,
|
||||
pathname,
|
||||
name
|
||||
}
|
||||
};
|
||||
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
|
||||
} catch (err) {
|
||||
console.error(`Error processing unlinkDir event for: ${pathname}`, err);
|
||||
}
|
||||
};
|
||||
|
||||
const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
|
||||
@@ -932,6 +958,15 @@ class CollectionWatcher {
|
||||
.filter(([path, watcher]) => !!watcher)
|
||||
.map(([path, _watcher]) => path);
|
||||
}
|
||||
|
||||
closeAllWatchers() {
|
||||
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
|
||||
try {
|
||||
watcher?.close();
|
||||
} catch (err) {}
|
||||
}
|
||||
this.watchers = {};
|
||||
}
|
||||
}
|
||||
|
||||
const collectionWatcher = new CollectionWatcher();
|
||||
|
||||
@@ -27,6 +27,7 @@ const template = [
|
||||
},
|
||||
{
|
||||
label: 'Preferences',
|
||||
accelerator: 'CommandOrControl+,',
|
||||
click() {
|
||||
ipcMain.emit('main:open-preferences');
|
||||
}
|
||||
@@ -88,7 +89,7 @@ const template = [
|
||||
},
|
||||
{
|
||||
role: 'window',
|
||||
submenu: [{ role: 'minimize' }, { role: 'close' }]
|
||||
submenu: [{ role: 'minimize' }, { role: 'close', accelerator: 'CommandOrControl+Shift+Q' }]
|
||||
},
|
||||
{
|
||||
role: 'help',
|
||||
|
||||
@@ -224,6 +224,24 @@ class WorkspaceWatcher {
|
||||
hasWatcher(workspacePath) {
|
||||
return Boolean(this.watchers[workspacePath]);
|
||||
}
|
||||
|
||||
closeAllWatchers() {
|
||||
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
|
||||
try {
|
||||
watcher?.close();
|
||||
} catch (err) {}
|
||||
}
|
||||
this.watchers = {};
|
||||
|
||||
for (const [watchPath, watcher] of Object.entries(this.environmentWatchers)) {
|
||||
try {
|
||||
watcher?.close();
|
||||
} catch (err) {}
|
||||
}
|
||||
this.environmentWatchers = {};
|
||||
|
||||
dotEnvWatcher.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WorkspaceWatcher;
|
||||
|
||||
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
const { execSync } = require('node:child_process');
|
||||
const isDev = require('electron-is-dev');
|
||||
const os = require('os');
|
||||
const { initializeShellEnv } = require('@usebruno/requests');
|
||||
const { initializeShellEnv, waitForShellEnv } = require('./store/shell-env-state');
|
||||
const { percentageToZoomLevel } = require('@usebruno/common');
|
||||
|
||||
if (isDev) {
|
||||
@@ -122,6 +122,12 @@ const focusMainWindow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const closeAllWatchers = () => {
|
||||
collectionWatcher.closeAllWatchers();
|
||||
workspaceWatcher.closeAllWatchers();
|
||||
apiSpecWatcher.closeAllWatchers();
|
||||
};
|
||||
|
||||
// Parse protocol URL from command line arguments (if any)
|
||||
appProtocolUrl = getAppProtocolUrlFromArgv(process.argv);
|
||||
|
||||
@@ -175,8 +181,7 @@ if (useSingleInstance && !gotTheLock) {
|
||||
|
||||
// Prepare the renderer once the app is ready
|
||||
app.on('ready', async () => {
|
||||
// Ensure shell environment is loaded before any operations that need it
|
||||
await initializeShellEnv();
|
||||
initializeShellEnv();
|
||||
|
||||
if (isDev) {
|
||||
const { installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');
|
||||
@@ -197,9 +202,18 @@ app.on('ready', async () => {
|
||||
|
||||
// Initialize system proxy cache early (non-blocking)
|
||||
const { fetchSystemProxy } = require('./store/system-proxy');
|
||||
fetchSystemProxy().catch((err) => {
|
||||
console.warn('Failed to initialize system proxy cache:', err);
|
||||
});
|
||||
|
||||
// Note: irrespective of the state of the shell,
|
||||
// try to fetch the system proxy information
|
||||
waitForShellEnv()
|
||||
.catch((err) => {
|
||||
console.warn('Shell env init failed:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
fetchSystemProxy().catch((err) => {
|
||||
console.warn('Failed to initialize system proxy cache:', err);
|
||||
});
|
||||
});
|
||||
|
||||
Menu.setApplicationMenu(menu);
|
||||
const { maximized, x, y, width, height } = loadWindowState();
|
||||
@@ -216,8 +230,7 @@ app.on('ready', async () => {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
webviewTag: true,
|
||||
zoomFactor: 1.0
|
||||
webviewTag: true
|
||||
},
|
||||
title: 'Bruno',
|
||||
icon: path.join(__dirname, 'about/256x256.png'),
|
||||
@@ -247,29 +260,8 @@ app.on('ready', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle zoom shortcuts
|
||||
ipcMain.on('main:zoom-in', () => {
|
||||
if (mainWindow && mainWindow.webContents) {
|
||||
const currentZoom = mainWindow.webContents.getZoomLevel();
|
||||
mainWindow.webContents.setZoomLevel(currentZoom + 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('main:zoom-out', () => {
|
||||
if (mainWindow && mainWindow.webContents) {
|
||||
const currentZoom = mainWindow.webContents.getZoomLevel();
|
||||
mainWindow.webContents.setZoomLevel(currentZoom - 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('main:zoom-reset', () => {
|
||||
if (mainWindow && mainWindow.webContents) {
|
||||
mainWindow.webContents.setZoomLevel(0);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('renderer:window-close', () => {
|
||||
// if (!isWindows && !isLinux) return;
|
||||
if (!isWindows && !isLinux) return;
|
||||
mainWindow.close();
|
||||
});
|
||||
|
||||
@@ -477,6 +469,7 @@ app.on('ready', async () => {
|
||||
|
||||
// Quit the app once all windows are closed
|
||||
app.on('before-quit', () => {
|
||||
closeAllWatchers();
|
||||
// Release single instance lock to allow other instances to take over
|
||||
if (useSingleInstance && gotTheLock) {
|
||||
app.releaseSingleInstanceLock();
|
||||
@@ -505,6 +498,14 @@ app.on('open-file', (event, path) => {
|
||||
openCollection(mainWindow, collectionWatcher, path);
|
||||
});
|
||||
|
||||
// Register the global shortcuts
|
||||
app.on('browser-window-focus', () => {
|
||||
// Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996
|
||||
globalShortcut.register('Ctrl+=', () => {
|
||||
incrementZoomAndPersist(10);
|
||||
});
|
||||
});
|
||||
|
||||
// Disable global shortcuts when not focused
|
||||
app.on('browser-window-blur', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
|
||||
@@ -1271,11 +1271,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
|
||||
// Save OpenAPI spec file for sync support
|
||||
if (rawOpenAPISpec && brunoConfig.openapi?.length) {
|
||||
const importSourceUrl = brunoConfig.openapi[0].sourceUrl;
|
||||
const specContent = typeof rawOpenAPISpec === 'string'
|
||||
? rawOpenAPISpec
|
||||
: JSON.stringify(rawOpenAPISpec, null, 2);
|
||||
await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl: importSourceUrl });
|
||||
await saveSpecAndUpdateMetadata({ collectionPath, specContent });
|
||||
}
|
||||
|
||||
const { size, filesCount } = await getCollectionStats(collectionPath);
|
||||
|
||||
@@ -35,7 +35,7 @@ const { cookiesStore } = require('../../store/cookies');
|
||||
const registerGrpcEventHandlers = require('./grpc-event-handlers');
|
||||
const { registerWsEventHandlers } = require('./ws-event-handlers');
|
||||
const { getCertsAndProxyConfig, buildCertsAndProxyConfig } = require('./cert-utils');
|
||||
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
|
||||
const { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
|
||||
|
||||
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
|
||||
|
||||
@@ -594,17 +594,22 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
// if `data` is of string type - return as-is (assumes already encoded)
|
||||
}
|
||||
|
||||
if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) {
|
||||
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
|
||||
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
|
||||
if (!isFormData(request.data)) {
|
||||
request._originalMultipartData = request.data;
|
||||
request.collectionPath = collectionPath;
|
||||
let form = createFormData(request.data, collectionPath);
|
||||
request.data = form;
|
||||
if (contentTypeHeader !== 'multipart/form-data') {
|
||||
if (contentType !== 'multipart/form-data') {
|
||||
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
|
||||
const formHeaders = form.getHeaders();
|
||||
const ct = contentTypeHeader;
|
||||
formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`;
|
||||
const existingBoundary = extractBoundaryFromContentType(contentType);
|
||||
if (existingBoundary) {
|
||||
formHeaders['content-type'] = contentType;
|
||||
} else {
|
||||
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
|
||||
}
|
||||
form.getHeaders = function () {
|
||||
return formHeaders;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
stringifyFolder
|
||||
} = require('@usebruno/filestore');
|
||||
const { openApiToBruno } = require('@usebruno/converters');
|
||||
const { writeFile, sanitizeName, getCollectionFormat } = require('../utils/filesystem');
|
||||
const { writeFile, sanitizeName, getCollectionFormat, posixifyPath } = require('../utils/filesystem');
|
||||
const { getEnvVars } = require('../utils/collection');
|
||||
const { getProcessEnvVars } = require('../store/process-env');
|
||||
const { getCertsAndProxyConfig } = require('./network/cert-utils');
|
||||
@@ -84,6 +84,11 @@ const isValidHttpUrl = (urlString) => {
|
||||
|
||||
const isLocalFilePath = (str) => !isValidHttpUrl(str) && typeof str === 'string' && str.length > 0;
|
||||
|
||||
const resolveSourceUrl = (collectionPath, sourceUrl) => {
|
||||
if (!sourceUrl || isValidHttpUrl(sourceUrl)) return sourceUrl;
|
||||
return path.resolve(collectionPath, sourceUrl);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the directory where OpenAPI spec files are stored in AppData.
|
||||
*/
|
||||
@@ -127,8 +132,8 @@ const getSpecEntriesForCollection = (collectionPath) => {
|
||||
/**
|
||||
* Get the spec entry for a specific sourceUrl within a collection.
|
||||
*/
|
||||
const getSpecEntryForUrl = (collectionPath, sourceUrl) => {
|
||||
return getSpecEntriesForCollection(collectionPath).find((e) => e.sourceUrl === sourceUrl) || null;
|
||||
const getSpecEntryForUrl = (collectionPath) => {
|
||||
return getSpecEntriesForCollection(collectionPath)[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -260,6 +265,14 @@ const loadBrunoConfig = (collectionPath) => {
|
||||
brunoConfig = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8'));
|
||||
}
|
||||
|
||||
// Resolve relative openapi sourceUrls to absolute so all callers get consistent paths
|
||||
if (Array.isArray(brunoConfig?.openapi)) {
|
||||
brunoConfig.openapi = brunoConfig.openapi.map((entry) => ({
|
||||
...entry,
|
||||
sourceUrl: resolveSourceUrl(collectionPath, entry.sourceUrl)
|
||||
}));
|
||||
}
|
||||
|
||||
return { format, brunoConfig, collectionRoot };
|
||||
};
|
||||
|
||||
@@ -267,12 +280,23 @@ const loadBrunoConfig = (collectionPath) => {
|
||||
* Save bruno config to disk (bruno.json or opencollection.yml).
|
||||
*/
|
||||
const saveBrunoConfig = async (collectionPath, format, brunoConfig, collectionRoot) => {
|
||||
// Convert absolute openapi sourceUrls back to collection-relative for git-shareability
|
||||
const configToSave = { ...brunoConfig };
|
||||
if (Array.isArray(configToSave?.openapi)) {
|
||||
configToSave.openapi = configToSave.openapi.map((entry) => ({
|
||||
...entry,
|
||||
sourceUrl: (entry.sourceUrl && !isValidHttpUrl(entry.sourceUrl))
|
||||
? posixifyPath(path.relative(collectionPath, entry.sourceUrl))
|
||||
: entry.sourceUrl
|
||||
}));
|
||||
}
|
||||
|
||||
if (format === 'yml') {
|
||||
const content = await stringifyCollection(collectionRoot, brunoConfig, { format });
|
||||
const content = await stringifyCollection(collectionRoot, configToSave, { format });
|
||||
await writeFile(path.join(collectionPath, 'opencollection.yml'), content);
|
||||
} else {
|
||||
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
|
||||
await writeFile(brunoJsonPath, JSON.stringify(brunoConfig, null, 2));
|
||||
await writeFile(brunoJsonPath, JSON.stringify(configToSave, null, 2));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -346,9 +370,9 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => {
|
||||
const specsDir = getSpecsDir();
|
||||
await fsExtra.ensureDir(specsDir);
|
||||
|
||||
const resolvedUrl = resolveSourceUrl(collectionPath, sourceUrl);
|
||||
const meta = loadSpecMetadata();
|
||||
const entries = meta[collectionPath] || [];
|
||||
const existingEntry = entries.find((e) => e.sourceUrl === sourceUrl);
|
||||
const existingEntry = (meta[collectionPath] || [])[0];
|
||||
|
||||
let filename;
|
||||
if (existingEntry) {
|
||||
@@ -358,10 +382,12 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => {
|
||||
// Generate a new UUID filename based on content type
|
||||
const ext = isYamlContent(content) ? 'yaml' : 'json';
|
||||
filename = `${crypto.randomUUID()}.${ext}`;
|
||||
meta[collectionPath] = [...entries, { filename, sourceUrl }];
|
||||
saveSpecMetadata(meta);
|
||||
}
|
||||
|
||||
// Always replace with a single entry (one spec per collection for now)
|
||||
meta[collectionPath] = [{ filename, sourceUrl: resolvedUrl }];
|
||||
saveSpecMetadata(meta);
|
||||
|
||||
await writeFile(path.join(specsDir, filename), content);
|
||||
};
|
||||
|
||||
@@ -369,8 +395,9 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => {
|
||||
* Save an OpenAPI spec file and update sync metadata (lastSyncDate, specHash) in brunoConfig.
|
||||
* Shared by both the IPC handler (connect flow) and the import flow.
|
||||
*/
|
||||
const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUrl }) => {
|
||||
const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent }) => {
|
||||
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
|
||||
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
|
||||
|
||||
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
|
||||
|
||||
@@ -383,14 +410,9 @@ const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUr
|
||||
|
||||
const specHash = generateSpecHash(parsedSpec);
|
||||
const lastSyncDate = new Date().toISOString();
|
||||
const openapi = brunoConfig.openapi || [];
|
||||
const idx = openapi.findIndex((e) => e.sourceUrl === sourceUrl);
|
||||
if (idx !== -1) {
|
||||
openapi[idx] = { ...openapi[idx], lastSyncDate, specHash };
|
||||
} else {
|
||||
openapi.push({ sourceUrl, lastSyncDate, specHash });
|
||||
}
|
||||
brunoConfig.openapi = openapi;
|
||||
if (brunoConfig.openapi?.[0]) {
|
||||
brunoConfig.openapi[0] = { ...brunoConfig.openapi[0], lastSyncDate, specHash };
|
||||
};
|
||||
|
||||
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
|
||||
};
|
||||
@@ -417,7 +439,7 @@ const cleanupSpecFilesForCollection = (collectionPath) => {
|
||||
* Only preserves the user's enabled state; values come from the spec.
|
||||
*/
|
||||
const mergeWithUserValues = (specItems, existingItems) => {
|
||||
return specItems?.map((specItem) => {
|
||||
return (specItems || []).map((specItem) => {
|
||||
const existing = (existingItems || []).find(
|
||||
(e) => e.name === specItem.name && e.value === specItem.value
|
||||
);
|
||||
@@ -440,7 +462,12 @@ const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } =
|
||||
return {
|
||||
...existingRequest,
|
||||
request: {
|
||||
...specItem.request,
|
||||
...existingRequest.request,
|
||||
url: specItem.request.url,
|
||||
method: specItem.request.method,
|
||||
body: specItem.request.body,
|
||||
auth: specItem.request.auth,
|
||||
docs: specItem.request.docs,
|
||||
params: mergedParams || [],
|
||||
headers: mergedHeaders || []
|
||||
}
|
||||
@@ -509,13 +536,146 @@ const buildSpecItemsMap = (collectionItems) => {
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively extracts all key paths from a parsed JSON value (dot-notation).
|
||||
* Used to compare JSON body structure/schema without comparing values.
|
||||
*/
|
||||
const extractJsonKeys = (obj, prefix = '') => {
|
||||
const keys = [];
|
||||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
keys.push(fullKey);
|
||||
keys.push(...extractJsonKeys(obj[key], fullKey));
|
||||
}
|
||||
} else if (Array.isArray(obj) && obj.length > 0) {
|
||||
// Only inspect first element (spec arrays always have one template item)
|
||||
keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two Bruno-format requests field-by-field.
|
||||
* Returns { hasDiff, changes } where changes is an array of human-readable strings.
|
||||
*/
|
||||
const compareRequestFields = (specRequest, actualRequest) => {
|
||||
// Compare parameters by name:type pairs (catches query<->path type changes)
|
||||
const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
|
||||
// Compare headers (by name)
|
||||
const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
|
||||
const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
|
||||
|
||||
// Check for differences
|
||||
const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
|
||||
const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
|
||||
|
||||
// Check body mode difference
|
||||
const specBodyMode = specRequest.body?.mode || 'none';
|
||||
const actualBodyMode = actualRequest.body?.mode || 'none';
|
||||
const bodyDiff = specBodyMode !== actualBodyMode;
|
||||
|
||||
// Check auth mode difference
|
||||
const specAuthMode = specRequest.auth?.mode || 'none';
|
||||
const actualAuthMode = actualRequest.auth?.mode || 'none';
|
||||
const authDiff = specAuthMode !== actualAuthMode;
|
||||
|
||||
// Check auth config differences when auth modes match
|
||||
let authConfigDiff = false;
|
||||
if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
|
||||
if (specAuthMode === 'apikey') {
|
||||
const specApikey = specRequest.auth?.apikey || {};
|
||||
const actualApikey = actualRequest.auth?.apikey || {};
|
||||
authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
|
||||
} else if (specAuthMode === 'oauth2') {
|
||||
const specOauth2 = specRequest.auth?.oauth2 || {};
|
||||
const actualOauth2 = actualRequest.auth?.oauth2 || {};
|
||||
const grantType = specOauth2.grantType || actualOauth2.grantType;
|
||||
const commonFields = ['grantType', 'scope'];
|
||||
const grantTypeFields = {
|
||||
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
|
||||
implicit: [...commonFields, 'authorizationUrl'],
|
||||
password: [...commonFields, 'accessTokenUrl'],
|
||||
client_credentials: [...commonFields, 'accessTokenUrl']
|
||||
};
|
||||
const fields = grantTypeFields[grantType] || commonFields;
|
||||
authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check form field names when body modes match and mode is form-based
|
||||
let formFieldsDiff = false;
|
||||
let specFormFieldNames = [];
|
||||
let actualFormFieldNames = [];
|
||||
if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
|
||||
if (specBodyMode === 'multipartForm') {
|
||||
specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
} else {
|
||||
specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
}
|
||||
formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
|
||||
}
|
||||
|
||||
// Check JSON body structure when both sides use json mode
|
||||
let jsonBodyDiff = false;
|
||||
if (!bodyDiff && specBodyMode === 'json') {
|
||||
try {
|
||||
const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
|
||||
const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
|
||||
if (specJson !== null && actualJson !== null) {
|
||||
const specKeys = extractJsonKeys(specJson).sort();
|
||||
const actualKeys = extractJsonKeys(actualJson).sort();
|
||||
jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
|
||||
} else if ((specJson === null) !== (actualJson === null)) {
|
||||
jsonBodyDiff = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Malformed JSON — skip structural comparison
|
||||
}
|
||||
}
|
||||
|
||||
const hasDiff = paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff;
|
||||
|
||||
const changes = [];
|
||||
if (hasDiff) {
|
||||
if (paramsDiff) {
|
||||
const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
|
||||
const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
|
||||
if (addedParams.length) changes.push(`+${addedParams.length} params`);
|
||||
if (removedParams.length) changes.push(`-${removedParams.length} params`);
|
||||
}
|
||||
if (headersDiff) {
|
||||
const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
|
||||
const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
|
||||
if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
|
||||
if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
|
||||
}
|
||||
if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
|
||||
if (authDiff) changes.push(`auth: ${actualAuthMode}`);
|
||||
if (authConfigDiff) changes.push('auth config');
|
||||
if (formFieldsDiff) {
|
||||
const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
|
||||
const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
|
||||
if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
|
||||
if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
|
||||
}
|
||||
if (jsonBodyDiff) changes.push('body schema');
|
||||
}
|
||||
|
||||
return { hasDiff, changes };
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the stored spec for a collection and convert it to Bruno collection format.
|
||||
* Throws if no stored spec file exists.
|
||||
*/
|
||||
const loadStoredSpecCollection = (collectionPath, brunoConfig) => {
|
||||
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
|
||||
const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath, sourceUrl) : null;
|
||||
const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath) : null;
|
||||
const specPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null;
|
||||
|
||||
if (!specPath || !fs.existsSync(specPath)) {
|
||||
@@ -549,127 +709,49 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
collectionUid, collectionPath, sourceUrl, environmentContext
|
||||
}) => {
|
||||
try {
|
||||
// Get the title/name from the spec
|
||||
const getSpecTitle = (spec) => {
|
||||
return spec?.info?.title || null;
|
||||
};
|
||||
// Compare two OpenAPI specs by converting both to Bruno format and using field-level comparison.
|
||||
// This ensures specDrift uses the same comparison sensitivity as collectionDrift/remoteDrift.
|
||||
const compareSpecs = (oldSpec, newSpec, groupBy) => {
|
||||
// Convert both specs to Bruno collection format
|
||||
const oldBruno = oldSpec ? openApiToBruno(oldSpec, { groupBy }) : { items: [] };
|
||||
const newBruno = newSpec ? openApiToBruno(newSpec, { groupBy }) : { items: [] };
|
||||
|
||||
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
||||
|
||||
const normalizePath = (pathStr) => {
|
||||
return pathStr
|
||||
.replace(/{([^}]+)}/g, ':$1')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
};
|
||||
|
||||
const extractEndpoints = (spec) => {
|
||||
const endpoints = [];
|
||||
if (!spec || !spec.paths) return endpoints;
|
||||
|
||||
// Get base URL from servers
|
||||
const baseUrl = spec.servers?.[0]?.url || '';
|
||||
|
||||
Object.entries(spec.paths).forEach(([pathStr, methods]) => {
|
||||
if (!methods || typeof methods !== 'object') return;
|
||||
|
||||
Object.entries(methods).forEach(([method, operation]) => {
|
||||
if (!HTTP_METHODS.includes(method.toLowerCase())) return;
|
||||
|
||||
// Extract parameters
|
||||
const parameters = operation?.parameters || [];
|
||||
const pathParams = parameters.filter((p) => p.in === 'path');
|
||||
const queryParams = parameters.filter((p) => p.in === 'query');
|
||||
const headerParams = parameters.filter((p) => p.in === 'header');
|
||||
|
||||
// Extract request body
|
||||
const requestBody = operation?.requestBody;
|
||||
const bodyContent = requestBody?.content;
|
||||
const bodySchema = bodyContent?.['application/json']?.schema
|
||||
|| bodyContent?.['application/x-www-form-urlencoded']?.schema
|
||||
|| bodyContent?.['multipart/form-data']?.schema;
|
||||
const bodyExample = bodyContent?.['application/json']?.example
|
||||
|| bodyContent?.['application/json']?.examples;
|
||||
|
||||
// Extract responses
|
||||
const responses = operation?.responses || {};
|
||||
|
||||
endpoints.push({
|
||||
id: `${method.toUpperCase()}:${normalizePath(pathStr)}`,
|
||||
method: method.toUpperCase(),
|
||||
path: pathStr,
|
||||
normalizedPath: normalizePath(pathStr),
|
||||
operationId: operation?.operationId || null,
|
||||
summary: operation?.summary || null,
|
||||
description: operation?.description || null,
|
||||
tags: operation?.tags || [],
|
||||
deprecated: operation?.deprecated || false,
|
||||
// Detailed info for UI
|
||||
details: {
|
||||
parameters: {
|
||||
path: pathParams,
|
||||
query: queryParams,
|
||||
header: headerParams
|
||||
},
|
||||
requestBody: requestBody ? {
|
||||
required: requestBody.required || false,
|
||||
contentType: Object.keys(bodyContent || {})[0] || null,
|
||||
schema: bodySchema,
|
||||
example: bodyExample
|
||||
} : null,
|
||||
responses: Object.entries(responses).map(([code, resp]) => ({
|
||||
code,
|
||||
description: resp.description,
|
||||
schema: resp.content?.['application/json']?.schema
|
||||
}))
|
||||
},
|
||||
// Hash for comparison (MD5 for quick change detection)
|
||||
_hash: crypto.createHash('md5').update(JSON.stringify({
|
||||
parameters,
|
||||
requestBody: operation?.requestBody,
|
||||
responses: operation?.responses
|
||||
})).digest('hex')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
};
|
||||
|
||||
const compareSpecs = (oldSpec, newSpec) => {
|
||||
const oldEndpoints = extractEndpoints(oldSpec);
|
||||
const newEndpoints = extractEndpoints(newSpec);
|
||||
|
||||
const oldEndpointMap = new Map(oldEndpoints.map((ep) => [ep.id, ep]));
|
||||
const newEndpointMap = new Map(newEndpoints.map((ep) => [ep.id, ep]));
|
||||
// Build endpoint maps keyed by METHOD:normalizedPath
|
||||
const oldItems = buildSpecItemsMap(oldBruno.items || []);
|
||||
const newItems = buildSpecItemsMap(newBruno.items || []);
|
||||
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const modified = [];
|
||||
const unchanged = [];
|
||||
|
||||
newEndpoints.forEach((endpoint) => {
|
||||
if (!oldEndpointMap.has(endpoint.id)) {
|
||||
added.push(endpoint);
|
||||
for (const [id, newItem] of newItems) {
|
||||
const colonIndex = id.indexOf(':');
|
||||
const method = id.substring(0, colonIndex);
|
||||
const urlPath = id.substring(colonIndex + 1);
|
||||
|
||||
if (!oldItems.has(id)) {
|
||||
added.push({ id, method, path: urlPath, name: newItem.name });
|
||||
} else {
|
||||
const oldEndpoint = oldEndpointMap.get(endpoint.id);
|
||||
// Check if endpoint was modified by comparing hashes
|
||||
if (oldEndpoint._hash !== endpoint._hash) {
|
||||
modified.push({
|
||||
...endpoint,
|
||||
oldEndpoint: oldEndpoint
|
||||
});
|
||||
const oldItem = oldItems.get(id);
|
||||
const { hasDiff, changes } = compareRequestFields(oldItem.request, newItem.request);
|
||||
if (hasDiff) {
|
||||
modified.push({ id, method, path: urlPath, name: newItem.name, changes: changes.join(', ') });
|
||||
} else {
|
||||
unchanged.push(endpoint);
|
||||
unchanged.push({ id, method, path: urlPath, name: newItem.name });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
oldEndpoints.forEach((endpoint) => {
|
||||
if (!newEndpointMap.has(endpoint.id)) {
|
||||
removed.push(endpoint);
|
||||
for (const [id] of oldItems) {
|
||||
if (!newItems.has(id)) {
|
||||
const colonIndex = id.indexOf(':');
|
||||
const method = id.substring(0, colonIndex);
|
||||
const urlPath = id.substring(colonIndex + 1);
|
||||
const oldItem = oldItems.get(id);
|
||||
removed.push({ id, method, path: urlPath, name: oldItem.name });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Compare metadata (title, version, description)
|
||||
const oldTitle = oldSpec?.info?.title || null;
|
||||
@@ -706,7 +788,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
};
|
||||
};
|
||||
|
||||
const specEntry = getSpecEntryForUrl(collectionPath, sourceUrl);
|
||||
const specEntry = getSpecEntryForUrl(collectionPath);
|
||||
const storedSpecPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null;
|
||||
|
||||
let storedSpec = null;
|
||||
@@ -746,8 +828,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
|
||||
// Check for title/name changes
|
||||
const storedTitle = getSpecTitle(storedSpec);
|
||||
const newTitle = getSpecTitle(newSpec);
|
||||
const storedTitle = storedSpec?.info?.title || null;
|
||||
const newTitle = newSpec?.info?.title || null;
|
||||
const titleChanged = storedSpec && storedTitle && newTitle && storedTitle !== newTitle;
|
||||
|
||||
// Generate hashes for quick change detection
|
||||
@@ -755,7 +837,16 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
const remoteSpecHash = generateSpecHash(newSpec);
|
||||
const hasRemoteChanges = storedSpecHash !== remoteSpecHash;
|
||||
|
||||
const diff = compareSpecs(storedSpec, newSpec);
|
||||
// Read groupBy from brunoConfig for consistent spec conversion
|
||||
let groupBy = 'tags';
|
||||
try {
|
||||
const { brunoConfig } = loadBrunoConfig(collectionPath);
|
||||
groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
|
||||
} catch (e) {
|
||||
// Default to 'tags' if brunoConfig is not available
|
||||
}
|
||||
|
||||
const diff = compareSpecs(storedSpec, newSpec, groupBy);
|
||||
|
||||
// Detect remote spec format and determine correct filename
|
||||
const remoteIsYaml = isYamlContent(newSpecContent);
|
||||
@@ -801,36 +892,14 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Recursively extracts all key paths from a parsed JSON value (dot-notation).
|
||||
// Used to compare JSON body structure/schema without comparing values.
|
||||
const extractJsonKeys = (obj, prefix = '') => {
|
||||
const keys = [];
|
||||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
keys.push(fullKey);
|
||||
keys.push(...extractJsonKeys(obj[key], fullKey));
|
||||
}
|
||||
} else if (Array.isArray(obj) && obj.length > 0) {
|
||||
// Only inspect first element (spec arrays always have one template item)
|
||||
keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
// Collection Drift Detection - compare stored spec (converted to bru) vs actual .bru files
|
||||
ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, brunoConfig: passedBrunoConfig, compareSpec }) => {
|
||||
ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, compareSpec }) => {
|
||||
try {
|
||||
// Use passed brunoConfig if available, otherwise read from disk
|
||||
let brunoConfig;
|
||||
if (passedBrunoConfig) {
|
||||
brunoConfig = passedBrunoConfig;
|
||||
} else {
|
||||
try {
|
||||
({ brunoConfig } = loadBrunoConfig(collectionPath));
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
try {
|
||||
({ brunoConfig } = loadBrunoConfig(collectionPath));
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
|
||||
// Load spec to compare against — use compareSpec if provided, otherwise read stored spec from disk
|
||||
@@ -841,7 +910,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
specToCompare = compareSpec;
|
||||
} else {
|
||||
const driftSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
|
||||
const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath, driftSourceUrl) : null;
|
||||
const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath) : null;
|
||||
const storedSpecPath = driftSpecEntry ? path.join(getSpecsDir(), driftSpecEntry.filename) : null;
|
||||
|
||||
if (!storedSpecPath || !fs.existsSync(storedSpecPath)) {
|
||||
@@ -936,113 +1005,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
});
|
||||
} else {
|
||||
// Compare key fields to detect drift
|
||||
const specRequest = specItem.request;
|
||||
|
||||
// Compare parameters by name:type pairs (catches query<->path type changes)
|
||||
const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
|
||||
|
||||
// Compare headers (by name)
|
||||
const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
|
||||
const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
|
||||
|
||||
// Check for differences
|
||||
const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
|
||||
const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
|
||||
|
||||
// Check body mode difference
|
||||
const specBodyMode = specRequest.body?.mode || 'none';
|
||||
const actualBodyMode = actualRequest.body?.mode || 'none';
|
||||
const bodyDiff = specBodyMode !== actualBodyMode;
|
||||
|
||||
// Check auth mode difference
|
||||
const specAuthMode = specRequest.auth?.mode || 'none';
|
||||
const actualAuthMode = actualRequest.auth?.mode || 'none';
|
||||
const authDiff = specAuthMode !== actualAuthMode;
|
||||
|
||||
// Check auth config differences when auth modes match
|
||||
let authConfigDiff = false;
|
||||
if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
|
||||
if (specAuthMode === 'apikey') {
|
||||
const specApikey = specRequest.auth?.apikey || {};
|
||||
const actualApikey = actualRequest.auth?.apikey || {};
|
||||
authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
|
||||
} else if (specAuthMode === 'oauth2') {
|
||||
const specOauth2 = specRequest.auth?.oauth2 || {};
|
||||
const actualOauth2 = actualRequest.auth?.oauth2 || {};
|
||||
const grantType = specOauth2.grantType || actualOauth2.grantType;
|
||||
const commonFields = ['grantType', 'scope'];
|
||||
const grantTypeFields = {
|
||||
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
|
||||
implicit: [...commonFields, 'authorizationUrl'],
|
||||
password: [...commonFields, 'accessTokenUrl'],
|
||||
client_credentials: [...commonFields, 'accessTokenUrl']
|
||||
};
|
||||
const fields = grantTypeFields[grantType] || commonFields;
|
||||
authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check form field names when body modes match and mode is form-based
|
||||
let formFieldsDiff = false;
|
||||
let specFormFieldNames = [];
|
||||
let actualFormFieldNames = [];
|
||||
if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
|
||||
if (specBodyMode === 'multipartForm') {
|
||||
// For multipartForm, compare name:type pairs to catch text<->file changes
|
||||
specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
|
||||
} else {
|
||||
// For formUrlEncoded, all fields are text — compare by name only
|
||||
specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
|
||||
}
|
||||
formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
|
||||
}
|
||||
|
||||
// Check JSON body structure when both sides use json mode
|
||||
let jsonBodyDiff = false;
|
||||
if (!bodyDiff && specBodyMode === 'json') {
|
||||
try {
|
||||
const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
|
||||
const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
|
||||
if (specJson !== null && actualJson !== null) {
|
||||
const specKeys = extractJsonKeys(specJson).sort();
|
||||
const actualKeys = extractJsonKeys(actualJson).sort();
|
||||
jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
|
||||
} else if ((specJson === null) !== (actualJson === null)) {
|
||||
jsonBodyDiff = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Malformed JSON — skip structural comparison
|
||||
}
|
||||
}
|
||||
|
||||
if (paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff) {
|
||||
const changes = [];
|
||||
if (paramsDiff) {
|
||||
const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
|
||||
const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
|
||||
if (addedParams.length) changes.push(`+${addedParams.length} params`);
|
||||
if (removedParams.length) changes.push(`-${removedParams.length} params`);
|
||||
}
|
||||
if (headersDiff) {
|
||||
const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
|
||||
const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
|
||||
if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
|
||||
if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
|
||||
}
|
||||
if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
|
||||
if (authDiff) changes.push(`auth: ${actualAuthMode}`);
|
||||
if (authConfigDiff) changes.push('auth config');
|
||||
if (formFieldsDiff) {
|
||||
const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
|
||||
const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
|
||||
if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
|
||||
if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
|
||||
}
|
||||
if (jsonBodyDiff) changes.push('body schema');
|
||||
const { hasDiff, changes } = compareRequestFields(specItem.request, actualRequest);
|
||||
|
||||
if (hasDiff) {
|
||||
result.modified.push({
|
||||
id,
|
||||
method,
|
||||
@@ -1114,7 +1079,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
let specToUse = newSpec;
|
||||
if (!specToUse) {
|
||||
const diffSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
|
||||
const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath, diffSourceUrl) : null;
|
||||
const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath) : null;
|
||||
const storedSpecPath = diffSpecEntry ? path.join(getSpecsDir(), diffSpecEntry.filename) : null;
|
||||
if (storedSpecPath && fs.existsSync(storedSpecPath)) {
|
||||
const content = fs.readFileSync(storedSpecPath, 'utf8');
|
||||
@@ -1192,9 +1157,10 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
// Sync modes: 'spec-only' | 'reset' | 'sync' (default)
|
||||
ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, sourceUrl, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => {
|
||||
ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => {
|
||||
try {
|
||||
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
|
||||
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
|
||||
|
||||
// Mode: spec-only - Just save the spec, don't touch collection
|
||||
if (mode === 'spec-only') {
|
||||
@@ -1204,16 +1170,13 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
const openapi = brunoConfig.openapi || [];
|
||||
const specOnlyIdx = openapi.findIndex((e) => e.sourceUrl === sourceUrl);
|
||||
if (specOnlyIdx !== -1) {
|
||||
openapi[specOnlyIdx] = {
|
||||
...openapi[specOnlyIdx],
|
||||
if (brunoConfig.openapi?.[0]) {
|
||||
brunoConfig.openapi[0] = {
|
||||
...brunoConfig.openapi[0],
|
||||
lastSyncDate: new Date().toISOString(),
|
||||
specHash: generateSpecHash(diff.newSpec)
|
||||
};
|
||||
}
|
||||
brunoConfig.openapi = openapi;
|
||||
|
||||
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
|
||||
|
||||
@@ -1222,8 +1185,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
|
||||
// Mode: reset - Save spec and reset all endpoints to spec (preserve tests/scripts)
|
||||
if (mode === 'reset' && diff.newSpec) {
|
||||
const openapiEntryReset = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl);
|
||||
const groupBy = openapiEntryReset?.groupBy || 'tags';
|
||||
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
|
||||
const newCollection = openApiToBruno(diff.newSpec, { groupBy });
|
||||
|
||||
// Build map of spec items by endpoint ID
|
||||
@@ -1288,16 +1250,13 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
|
||||
|
||||
// Update sync metadata
|
||||
const openapiReset = brunoConfig.openapi || [];
|
||||
const resetIdx = openapiReset.findIndex((e) => e.sourceUrl === sourceUrl);
|
||||
if (resetIdx !== -1) {
|
||||
openapiReset[resetIdx] = {
|
||||
...openapiReset[resetIdx],
|
||||
if (brunoConfig.openapi?.[0]) {
|
||||
brunoConfig.openapi[0] = {
|
||||
...brunoConfig.openapi[0],
|
||||
lastSyncDate: new Date().toISOString(),
|
||||
specHash: generateSpecHash(diff.newSpec)
|
||||
};
|
||||
}
|
||||
brunoConfig.openapi = openapiReset;
|
||||
|
||||
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
|
||||
|
||||
@@ -1305,8 +1264,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
|
||||
// Mode: sync (default) — compute shared values once
|
||||
const syncEntry = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl);
|
||||
const groupBy = syncEntry?.groupBy || 'tags';
|
||||
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
|
||||
let newCollection;
|
||||
if (diff.newSpec) {
|
||||
try {
|
||||
@@ -1316,35 +1274,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (addNewRequests && diff.added?.length > 0 && newCollection) {
|
||||
for (const endpoint of diff.added) {
|
||||
const normalizedPath = normalizeUrlPath(endpoint.path);
|
||||
const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
|
||||
const newItem = result?.item;
|
||||
|
||||
if (newItem) {
|
||||
// Check if endpoint already exists in collection (prevents overwriting user customizations)
|
||||
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
|
||||
|
||||
if (existingFile) {
|
||||
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
|
||||
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
|
||||
await writeFile(existingFile.filePath, content);
|
||||
} else {
|
||||
// Truly new — create file in the appropriate folder
|
||||
let targetFolder = collectionPath;
|
||||
if (result.folderName && groupBy === 'tags') {
|
||||
targetFolder = await ensureTagFolder(collectionPath, result.folderName, format);
|
||||
}
|
||||
|
||||
const requestContent = await stringifyRequestViaWorker(newItem, { format });
|
||||
const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`;
|
||||
await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove endpoints before adding new ones to avoid filename collisions
|
||||
// (e.g., when a path is renamed but the summary stays the same, both generate the same filename)
|
||||
if (removeDeletedRequests && diff.removed?.length > 0) {
|
||||
const findAndRemoveRequest = (dirPath) => {
|
||||
if (!fs.existsSync(dirPath)) return;
|
||||
@@ -1389,6 +1320,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
}
|
||||
|
||||
// Remove local-only endpoints (endpoints in collection but not in spec)
|
||||
// Verify file content before deleting — the file may have been modified by the user
|
||||
// between the drift scan and sync execution, making the pre-computed filePath stale.
|
||||
if (localOnlyToRemove?.length > 0) {
|
||||
for (const endpoint of localOnlyToRemove) {
|
||||
if (endpoint.filePath) {
|
||||
@@ -1398,7 +1331,49 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
continue;
|
||||
}
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
try {
|
||||
const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru';
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const parsed = parseRequest(content, { format: fileFormat });
|
||||
if (parsed?.request) {
|
||||
const fileMethod = parsed.request.method?.toUpperCase();
|
||||
const fileUrlPath = normalizeUrlPath(parsed.request.url);
|
||||
if (fileMethod === endpoint.method && fileUrlPath === endpoint.path) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[OpenAPI Sync] Error verifying file before removal ${endpoint.filePath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addNewRequests && diff.added?.length > 0 && newCollection) {
|
||||
for (const endpoint of diff.added) {
|
||||
const normalizedPath = normalizeUrlPath(endpoint.path);
|
||||
const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
|
||||
const newItem = result?.item;
|
||||
|
||||
if (newItem) {
|
||||
// Check if endpoint already exists in collection (prevents overwriting user customizations)
|
||||
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
|
||||
|
||||
if (existingFile) {
|
||||
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
|
||||
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
|
||||
await writeFile(existingFile.filePath, content);
|
||||
} else {
|
||||
// Truly new — create file in the appropriate folder
|
||||
let targetFolder = collectionPath;
|
||||
if (result.folderName && groupBy === 'tags') {
|
||||
targetFolder = await ensureTagFolder(collectionPath, result.folderName, format);
|
||||
}
|
||||
|
||||
const requestContent = await stringifyRequestViaWorker(newItem, { format });
|
||||
const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`;
|
||||
await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1436,7 +1411,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
// Reuse newCollection if available, otherwise fall back to stored spec
|
||||
let driftCollection = newCollection;
|
||||
if (!driftCollection) {
|
||||
const applySpecEntry = getSpecEntryForUrl(collectionPath, sourceUrl);
|
||||
const applySpecEntry = getSpecEntryForUrl(collectionPath);
|
||||
const storedSpecPath = applySpecEntry ? path.join(getSpecsDir(), applySpecEntry.filename) : null;
|
||||
if (storedSpecPath && fs.existsSync(storedSpecPath)) {
|
||||
try {
|
||||
@@ -1485,20 +1460,17 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
|
||||
}
|
||||
|
||||
const openapiSync = brunoConfig.openapi || [];
|
||||
const syncIdx = openapiSync.findIndex((e) => e.sourceUrl === sourceUrl);
|
||||
if (syncIdx !== -1) {
|
||||
if (brunoConfig.openapi?.[0]) {
|
||||
const updated = {
|
||||
...openapiSync[syncIdx],
|
||||
...brunoConfig.openapi[0],
|
||||
lastSyncDate: new Date().toISOString()
|
||||
};
|
||||
// Only update specHash when we have a valid newSpec, otherwise preserve existing hash
|
||||
if (diff.newSpec) {
|
||||
updated.specHash = generateSpecHash(diff.newSpec);
|
||||
}
|
||||
openapiSync[syncIdx] = updated;
|
||||
brunoConfig.openapi[0] = updated;
|
||||
}
|
||||
brunoConfig.openapi = openapiSync;
|
||||
|
||||
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
|
||||
|
||||
@@ -1510,7 +1482,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
// Update OpenAPI sync configuration (e.g., source URL)
|
||||
ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, oldSourceUrl, config }) => {
|
||||
ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, config }) => {
|
||||
try {
|
||||
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
|
||||
|
||||
@@ -1533,37 +1505,18 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
throw new Error('Invalid URL: only http and https URLs are allowed');
|
||||
}
|
||||
|
||||
// Convert absolute local file paths to collection-relative (git-shareable)
|
||||
if (path.isAbsolute(sanitizedConfig.sourceUrl)) {
|
||||
sanitizedConfig.sourceUrl = path.relative(collectionPath, sanitizedConfig.sourceUrl);
|
||||
}
|
||||
// Resolve to absolute for consistent internal handling (saveBrunoConfig converts back to relative)
|
||||
sanitizedConfig.sourceUrl = resolveSourceUrl(collectionPath, sanitizedConfig.sourceUrl);
|
||||
|
||||
// If sourceUrl is changing, remove the old entry and its metadata
|
||||
const openapi = brunoConfig.openapi || [];
|
||||
if (oldSourceUrl && oldSourceUrl !== sanitizedConfig.sourceUrl) {
|
||||
const filteredOpenapi = openapi.filter((e) => e.sourceUrl !== oldSourceUrl);
|
||||
brunoConfig.openapi = filteredOpenapi;
|
||||
// Clean up metadata entry for old sourceUrl (keep spec file for potential re-use)
|
||||
const meta = loadSpecMetadata();
|
||||
if (meta[collectionPath]) {
|
||||
meta[collectionPath] = meta[collectionPath].filter((e) => e.sourceUrl !== oldSourceUrl);
|
||||
if (meta[collectionPath].length === 0) delete meta[collectionPath];
|
||||
saveSpecMetadata(meta);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults for new entries
|
||||
const updatedOpenapi = brunoConfig.openapi || [];
|
||||
const idx = updatedOpenapi.findIndex((e) => e.sourceUrl === sanitizedConfig.sourceUrl);
|
||||
const isNewEntry = idx === -1;
|
||||
if (isNewEntry) {
|
||||
// Update or create the single openapi entry
|
||||
const existingEntry = brunoConfig.openapi?.[0];
|
||||
if (existingEntry) {
|
||||
brunoConfig.openapi = [{ ...existingEntry, ...sanitizedConfig }];
|
||||
} else {
|
||||
if (!('autoCheck' in sanitizedConfig)) sanitizedConfig.autoCheck = true;
|
||||
if (!('autoCheckInterval' in sanitizedConfig)) sanitizedConfig.autoCheckInterval = 5;
|
||||
updatedOpenapi.push(sanitizedConfig);
|
||||
} else {
|
||||
updatedOpenapi[idx] = { ...updatedOpenapi[idx], ...sanitizedConfig };
|
||||
brunoConfig.openapi = [sanitizedConfig];
|
||||
}
|
||||
brunoConfig.openapi = updatedOpenapi;
|
||||
|
||||
// Save updated config
|
||||
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
|
||||
@@ -1576,9 +1529,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
// Save OpenAPI spec file and update sync metadata (used by both connect and import flows)
|
||||
ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent, sourceUrl }) => {
|
||||
ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent }) => {
|
||||
try {
|
||||
await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl });
|
||||
await saveSpecAndUpdateMetadata({ collectionPath, specContent });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error saving OpenAPI spec file:', error);
|
||||
@@ -1606,9 +1559,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
// Read stored OpenAPI spec file from AppData
|
||||
ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath, sourceUrl }) => {
|
||||
ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath }) => {
|
||||
try {
|
||||
const entry = getSpecEntryForUrl(collectionPath, sourceUrl);
|
||||
const entry = getSpecEntryForUrl(collectionPath);
|
||||
if (!entry) return { error: 'Spec file not found' };
|
||||
const specPath = path.join(getSpecsDir(), entry.filename);
|
||||
if (!fs.existsSync(specPath)) return { error: 'Spec file not found' };
|
||||
@@ -1619,31 +1572,22 @@ const registerOpenAPISyncIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
// Remove OpenAPI sync configuration (disconnect sync)
|
||||
ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, sourceUrl, deleteSpecFile = false }) => {
|
||||
ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, deleteSpecFile = false }) => {
|
||||
try {
|
||||
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
|
||||
|
||||
// Remove matching openapi entry from config array
|
||||
if (brunoConfig.openapi?.length) {
|
||||
brunoConfig.openapi = brunoConfig.openapi.filter((e) => e.sourceUrl !== sourceUrl);
|
||||
if (brunoConfig.openapi.length === 0) {
|
||||
delete brunoConfig.openapi;
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated config
|
||||
// Remove openapi config
|
||||
delete brunoConfig.openapi;
|
||||
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
|
||||
|
||||
// Remove spec file from AppData if user opted in
|
||||
// Remove spec file and metadata for this collection
|
||||
const meta = loadSpecMetadata();
|
||||
const entries = meta[collectionPath] || [];
|
||||
const entry = entries.find((e) => e.sourceUrl === sourceUrl);
|
||||
const entry = (meta[collectionPath] || [])[0];
|
||||
if (entry && deleteSpecFile) {
|
||||
const specPath = path.join(getSpecsDir(), entry.filename);
|
||||
if (fs.existsSync(specPath)) fs.unlinkSync(specPath);
|
||||
}
|
||||
meta[collectionPath] = entries.filter((e) => e.sourceUrl !== sourceUrl);
|
||||
if (meta[collectionPath].length === 0) delete meta[collectionPath];
|
||||
delete meta[collectionPath];
|
||||
saveSpecMetadata(meta);
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -45,7 +45,9 @@ const defaultPreferences = {
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
},
|
||||
beta: {},
|
||||
beta: {
|
||||
'openapi-sync': false
|
||||
},
|
||||
onboarding: {
|
||||
hasLaunchedBefore: false,
|
||||
hasSeenWelcomeModal: true
|
||||
@@ -58,52 +60,6 @@ const defaultPreferences = {
|
||||
enabled: false,
|
||||
interval: 1000
|
||||
},
|
||||
keyBindings: {
|
||||
save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' },
|
||||
sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' },
|
||||
importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' },
|
||||
globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' },
|
||||
sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' },
|
||||
closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' },
|
||||
changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' },
|
||||
closeBruno: {
|
||||
mac: 'command+bind+q',
|
||||
windows: 'ctrl+bind+shift+bind+q',
|
||||
name: 'Close Bruno'
|
||||
},
|
||||
switchToPreviousTab: {
|
||||
mac: 'command+bind+2',
|
||||
windows: 'ctrl+bind+2',
|
||||
name: 'Switch to Previous Tab'
|
||||
},
|
||||
switchToNextTab: {
|
||||
mac: 'command+bind+1',
|
||||
windows: 'ctrl+bind+1',
|
||||
name: 'Switch to Next Tab'
|
||||
},
|
||||
moveTabLeft: {
|
||||
mac: 'command+bind+[',
|
||||
windows: 'ctrl+bind+[',
|
||||
name: 'Move Tab Left'
|
||||
},
|
||||
moveTabRight: {
|
||||
mac: 'command+bind+]',
|
||||
windows: 'ctrl+bind+]',
|
||||
name: 'Move Tab Right'
|
||||
},
|
||||
closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' },
|
||||
collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' },
|
||||
zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' },
|
||||
zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' },
|
||||
resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' },
|
||||
cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' },
|
||||
copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' },
|
||||
pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' },
|
||||
renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }
|
||||
},
|
||||
display: {
|
||||
zoomPercentage: 100
|
||||
},
|
||||
@@ -154,6 +110,7 @@ const preferencesSchema = Yup.object().shape({
|
||||
responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical'])
|
||||
}),
|
||||
beta: Yup.object({
|
||||
'openapi-sync': Yup.boolean()
|
||||
}),
|
||||
onboarding: Yup.object({
|
||||
hasLaunchedBefore: Yup.boolean(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user